#!/usr/bin/env python3
from __future__ import annotations

import argparse
import csv
from datetime import datetime, timezone
import json
import os
import sys
import time
from pathlib import Path

SCRIPT_DIR = Path(__file__).resolve().parent
sys.dont_write_bytecode = True
if str(SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(SCRIPT_DIR))

from contract import ContractValidationError, load_schema, validate_contract


class FinalizeInputError(ValueError):
    pass


OUTCOME_COLUMNS = ("shotId", "status", "targetTrackIndex", "message")
OUTCOME_STATUSES = {
    "PLACED",
    "LEFT_GAP",
    "MARKED_FOR_REVIEW",
    "SKIPPED_OCCUPIED",
    "FAILED",
}
ALTERNATIVE_OUTCOME_COLUMNS = (
    "shotId",
    "rank",
    "decision",
    "status",
    "trackIndex",
    "sourceId",
    "projectItemRef",
    "sourceStartSeconds",
    "sourceEndSeconds",
    "sequenceStartSeconds",
    "sequenceEndSeconds",
    "normalizedScore",
    "message",
)
ALTERNATIVE_OUTCOME_STATUSES = {
    "PLACED",
    "REUSED",
    "SKIPPED_OCCUPIED",
    "MISSING_TRACK",
    "INELIGIBLE",
    "FAILED",
}


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")


def format_duration(seconds: float) -> str:
    milliseconds = max(0, round(float(seconds) * 1000))
    hours, remainder = divmod(milliseconds, 3_600_000)
    minutes, remainder = divmod(remainder, 60_000)
    whole_seconds, millis = divmod(remainder, 1000)
    return f"{hours:02d}:{minutes:02d}:{whole_seconds:02d}.{millis:03d}"


def atomic_write_text(path: Path, value: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
    try:
        temporary.write_text(value, encoding="utf-8")
        os.replace(temporary, path)
    finally:
        temporary.unlink(missing_ok=True)


def warn_processing_time_report(path: Path, exc: Exception) -> None:
    message = str(exc).replace("|", "/").replace("\r", " ").replace("\n", " ")
    safe_path = str(path).replace("|", "/").replace("\r", " ").replace("\n", " ")
    print(
        f"WARNING|stage=processing-time-report|message={message}|path={safe_path}",
        file=sys.stderr,
        flush=True,
    )


def source_index_seconds(path: Path | None) -> float:
    if path is None or not path.is_file():
        return 0.0
    try:
        report = json.loads(path.read_text(encoding="utf-8"))
        return max(0.0, float((report.get("timing") or {}).get("totalSeconds", 0.0)))
    except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
        warn_processing_time_report(path, exc)
        return 0.0


def analysis_diagnostic_report_lines(document: dict) -> list[str]:
    run = document.get("run") or {}
    diagnostics = run.get("analysisDiagnostics") or {}
    timing = diagnostics.get("timing") or {}
    counts = diagnostics.get("counts") or {}
    exports = diagnostics.get("exactExports") or []
    retention = diagnostics.get("exactExportRetention") or {}
    shots = diagnostics.get("shots") or []
    if not timing or not counts:
        return []
    reference_ffmpeg_seconds = max(
        0.0,
        float(timing.get("referenceExportFfmpegSeconds", 0.0)),
    )
    source_ffmpeg_seconds = sum(
        max(0.0, float(timing.get(key, 0.0)))
        for key in (
            "refinementWindowFfmpegSeconds",
            "fixedFrameExportFfmpegSeconds",
            "alignmentExportFfmpegSeconds",
            "exactAnchorExportFfmpegSeconds",
        )
    )
    ffmpeg_seconds = reference_ffmpeg_seconds + source_ffmpeg_seconds
    reference_compute_seconds = sum(
        max(0.0, float(timing.get(key, 0.0)))
        for key in (
            "referenceDescriptorSeconds",
            "referencePixelIdentitySeconds",
            "referenceFeatureSeconds",
            "referenceMotionSeconds",
        )
    )
    source_compute_seconds = sum(
        max(0.0, float(timing.get(key, 0.0)))
        for key in (
            "windowDescriptorSeconds",
            "alignmentDescriptorSeconds",
            "exactDescriptorSeconds",
            "sourcePixelIdentitySeconds",
            "sourceFeatureSeconds",
            "featureComparisonSeconds",
            "sourceMotionSeconds",
            "motionComparisonSeconds",
        )
    )
    detector_setup_seconds = max(
        0.0,
        float(timing.get("featureDetectorSetupSeconds", 0.0)),
    )
    compute_seconds = (
        reference_compute_seconds
        + source_compute_seconds
        + detector_setup_seconds
    )
    max_pts_delta = max(
        (
            abs(float(record.get("requestedToDecodedDeltaSeconds", 0.0)))
            for record in exports
        ),
        default=0.0,
    )
    return [
        "",
        "Shot-analysis diagnostics:",
        f"FFmpeg media processing: {format_duration(ffmpeg_seconds)} ({ffmpeg_seconds:.3f} s)",
        (
            "  Reference frame exports: "
            f"{reference_ffmpeg_seconds:.3f} s"
        ),
        (
            "  Refinement-window decodes: "
            f"{float(timing.get('refinementWindowFfmpegSeconds', 0.0)):.3f} s"
        ),
        (
            "  Fixed refinement frame exports: "
            f"{float(timing.get('fixedFrameExportFfmpegSeconds', 0.0)):.3f} s"
        ),
        (
            "  Alignment frame exports: "
            f"{float(timing.get('alignmentExportFfmpegSeconds', 0.0)):.3f} s"
        ),
        (
            "  Exact anchor exports: "
            f"{float(timing.get('exactAnchorExportFfmpegSeconds', 0.0)):.3f} s"
        ),
        f"OpenCV scoring: {format_duration(compute_seconds)} ({compute_seconds:.3f} s)",
        f"  Reference-side compute: {reference_compute_seconds:.3f} s",
        f"  Source-side and comparison compute: {source_compute_seconds:.3f} s",
        f"  Feature-detector setup: {detector_setup_seconds:.3f} s",
        (
            "Reference image file reads: "
            f"{float(timing.get('referenceImageReadSeconds', 0.0)):.3f} s"
        ),
        (
            "Source image file reads: "
            f"{float(timing.get('sourceImageReadSeconds', 0.0)):.3f} s"
        ),
        f"FFmpeg invocations: {int(counts.get('ffmpegInvocations', 0))}",
        f"Refinement-window frames used: {int(counts.get('refinementWindowFrames', 0))}",
        f"Alignment frame exports: {int(counts.get('alignmentFrameExports', 0))}",
        f"Exact anchor frame exports: {int(counts.get('exactAnchorFrameExports', 0))}",
        (
            "Reference feature cache: "
            f"{int(counts.get('referenceFeatureCacheHits', 0))} reused / "
            f"{int(counts.get('referenceFeatureCacheMisses', 0))} prepared"
        ),
        (
            "Exact exports with decoded PTS evidence: "
            f"{len(exports)} retained / {int(retention.get('totalRecords', len(exports)))} total"
        ),
        f"Maximum requested-to-decoded PTS delta: {max_pts_delta:.9f} s",
        (
            "Exact exports outside nominal one-frame PTS tolerance: "
            f"{int(counts.get('exactExportTimestampWarnings', 0))}"
        ),
        f"Per-shot diagnostic rollups: {len(shots)}",
    ]


def write_processing_time_report(
    path: Path,
    document: dict,
    json_path: Path,
    index_seconds: float,
    analysis_seconds: float,
    placement_seconds: float,
    alternative_outcomes: list[dict[str, str]],
    alternative_outcomes_path: Path | None,
) -> None:
    status_counts: dict[str, int] = {}
    review_count = 0
    fallback_placed_count = 0
    low_confidence_gap_count = 0
    for shot in document["shots"]:
        status = str((shot.get("outcome") or {}).get("status", "UNKNOWN"))
        decision = str(shot.get("decision", ""))
        status_counts[status] = status_counts.get(status, 0) + 1
        if (
            decision in {"REVIEW_RECOMMENDED", "NO_RELIABLE_MATCH"}
            and status != "SKIPPED_OCCUPIED"
        ):
            review_count += 1
        if decision == "NO_RELIABLE_MATCH" and status == "PLACED":
            fallback_placed_count += 1
        if (
            decision in {"REVIEW_RECOMMENDED", "NO_RELIABLE_MATCH"}
            and status in {"LEFT_GAP", "MARKED_FOR_REVIEW"}
        ):
            low_confidence_gap_count += 1
    total_seconds = index_seconds + analysis_seconds + placement_seconds
    gap_count = status_counts.get("LEFT_GAP", 0) + status_counts.get(
        "MARKED_FOR_REVIEW", 0
    )
    alternative_status_counts: dict[str, int] = {}
    for outcome in alternative_outcomes:
        status = outcome["status"]
        alternative_status_counts[status] = alternative_status_counts.get(status, 0) + 1
    lines = [
        "Automation Agent - Reconstruct Edit From Reference",
        "",
        "Status: COMPLETED",
        f"Completed: {utc_now()}",
        f"Total processing time: {format_duration(total_seconds)}",
        f"Total processing time seconds: {total_seconds:.3f}",
        f"Source indexing: {format_duration(index_seconds)} ({index_seconds:.3f} s)",
        f"Shot analysis: {format_duration(analysis_seconds)} ({analysis_seconds:.3f} s)",
        *analysis_diagnostic_report_lines(document),
        (
            "Timeline placement, verification, and finalization: "
            f"{format_duration(placement_seconds)} ({placement_seconds:.3f} s)"
        ),
        f"Shots analyzed: {len(document['shots'])}",
        f"Placed: {status_counts.get('PLACED', 0)}",
        f"Placed low-confidence fallbacks: {fallback_placed_count}",
        f"Skipped because occupied: {status_counts.get('SKIPPED_OCCUPIED', 0)}",
        f"Left as gaps: {gap_count}",
        f"Low-confidence results left as gaps: {low_confidence_gap_count}",
        f"Flagged for review: {review_count}",
        f"Ranked alternatives placed: {alternative_status_counts.get('PLACED', 0)}",
        f"Existing ranked alternatives reused: {alternative_status_counts.get('REUSED', 0)}",
        (
            "Ranked alternatives not placed because their lane was occupied: "
            f"{alternative_status_counts.get('SKIPPED_OCCUPIED', 0)}"
        ),
        (
            "Ranked alternatives not placed because no existing lane was available: "
            f"{alternative_status_counts.get('MISSING_TRACK', 0)}"
        ),
        (
            "Ranked alternatives excluded as structurally unusable or redundant: "
            f"{alternative_status_counts.get('INELIGIBLE', 0)}"
        ),
        (
            "Ranked alternatives that failed to place or verify: "
            f"{alternative_status_counts.get('FAILED', 0)}"
        ),
        f"Detailed JSON report: {json_path}",
        *(
            [f"Ranked alternative placement report: {alternative_outcomes_path}"]
            if alternative_outcomes_path is not None
            else []
        ),
        "",
    ]
    atomic_write_text(path, "\n".join(lines))


def read_outcomes(path: Path) -> dict[str, dict[str, str]]:
    with path.open("r", encoding="utf-8", newline="") as handle:
        reader = csv.DictReader(handle, delimiter="\t", restkey="__extra__")
        fields = tuple(reader.fieldnames or ())
        if fields != OUTCOME_COLUMNS:
            raise FinalizeInputError(
                f"{path.name} columns must be exactly: {', '.join(OUTCOME_COLUMNS)}"
            )
        outcomes: dict[str, dict[str, str]] = {}
        for row_number, raw in enumerate(reader, start=2):
            if raw.pop("__extra__", None) is not None:
                raise FinalizeInputError(f"{path.name} row {row_number} contains extra fields")
            row = {name: str(value or "").strip() for name, value in raw.items()}
            if not any(row.values()):
                continue
            shot_id = row["shotId"]
            if not shot_id:
                raise FinalizeInputError(f"{path.name} row {row_number} has an empty shotId")
            if shot_id in outcomes:
                raise FinalizeInputError(f"{path.name} contains duplicate shotId {shot_id}")
            if row["status"] not in OUTCOME_STATUSES:
                raise FinalizeInputError(
                    f"{path.name} row {row_number} has unsupported status {row['status']}"
                )
            outcomes[shot_id] = row
    return outcomes


def read_alternative_outcomes(path: Path | None) -> list[dict[str, str]]:
    if path is None:
        return []
    with path.open("r", encoding="utf-8", newline="") as handle:
        reader = csv.DictReader(handle, delimiter="\t", restkey="__extra__")
        fields = tuple(reader.fieldnames or ())
        if fields != ALTERNATIVE_OUTCOME_COLUMNS:
            raise FinalizeInputError(
                f"{path.name} columns must be exactly: {', '.join(ALTERNATIVE_OUTCOME_COLUMNS)}"
            )
        outcomes: list[dict[str, str]] = []
        identities: set[tuple[str, int]] = set()
        for row_number, raw in enumerate(reader, start=2):
            if raw.pop("__extra__", None) is not None:
                raise FinalizeInputError(f"{path.name} row {row_number} contains extra fields")
            row = {name: str(value or "").strip() for name, value in raw.items()}
            if not any(row.values()):
                continue
            shot_id = row["shotId"]
            if not shot_id:
                raise FinalizeInputError(f"{path.name} row {row_number} has an empty shotId")
            try:
                rank = int(row["rank"])
            except ValueError as exc:
                raise FinalizeInputError(
                    f"{path.name} row {row_number} rank must be an integer"
                ) from exc
            if rank <= 1:
                raise FinalizeInputError(
                    f"{path.name} row {row_number} rank must be greater than one"
                )
            identity = (shot_id, rank)
            if identity in identities:
                raise FinalizeInputError(
                    f"{path.name} contains duplicate shot/rank {shot_id}/{rank}"
                )
            identities.add(identity)
            if row["status"] not in ALTERNATIVE_OUTCOME_STATUSES:
                raise FinalizeInputError(
                    f"{path.name} row {row_number} has unsupported status {row['status']}"
                )
            if row["trackIndex"]:
                try:
                    track_index = int(row["trackIndex"])
                except ValueError as exc:
                    raise FinalizeInputError(
                        f"{path.name} row {row_number} trackIndex must be an integer"
                    ) from exc
                if track_index < 0:
                    raise FinalizeInputError(
                        f"{path.name} row {row_number} trackIndex must be >= 0"
                    )
            outcomes.append(row)
    return outcomes


def finalize(document: dict, outcomes: dict[str, dict[str, str]]) -> dict:
    expected = {shot["shotId"] for shot in document["shots"]}
    actual = set(outcomes)
    if actual != expected:
        missing = sorted(expected - actual)
        unexpected = sorted(actual - expected)
        raise FinalizeInputError(
            f"Outcome shot IDs do not match analysis result; missing={missing}, unexpected={unexpected}"
        )
    for shot in document["shots"]:
        row = outcomes[shot["shotId"]]
        outcome = {"status": row["status"]}
        if row["targetTrackIndex"]:
            try:
                target_track_index = int(row["targetTrackIndex"])
            except ValueError as exc:
                raise FinalizeInputError(
                    f"Outcome {shot['shotId']} targetTrackIndex must be an integer"
                ) from exc
            if target_track_index < 0:
                raise FinalizeInputError(
                    f"Outcome {shot['shotId']} targetTrackIndex must be >= 0"
                )
            outcome["targetTrackIndex"] = target_track_index
        if row["message"]:
            outcome["message"] = row["message"][:2000]
        shot["outcome"] = outcome
    return document


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--analysis", required=True)
    parser.add_argument("--outcomes", required=True)
    parser.add_argument("--schema", required=True)
    parser.add_argument("--output", required=True)
    parser.add_argument("--source-index-report")
    parser.add_argument("--timing-report")
    parser.add_argument("--alternative-outcomes")
    args = parser.parse_args()

    schema = load_schema(Path(args.schema).resolve())
    analysis_path = Path(args.analysis).resolve()
    document = json.loads(analysis_path.read_text(encoding="utf-8"))
    validate_contract(document, schema)
    finalized = finalize(document, read_outcomes(Path(args.outcomes).resolve()))
    validate_contract(finalized, schema)

    output = Path(args.output).resolve()
    output.parent.mkdir(parents=True, exist_ok=True)
    temporary = output.with_name(f".{output.name}.{os.getpid()}.tmp")
    try:
        temporary.write_text(json.dumps(finalized, indent=2) + "\n", encoding="utf-8")
        os.replace(temporary, output)
    finally:
        temporary.unlink(missing_ok=True)
    analysis_seconds = max(
        0.0,
        float(((finalized.get("run") or {}).get("timing") or {}).get("totalSeconds", 0.0)),
    )
    placement_seconds = max(0.0, time.time() - analysis_path.stat().st_mtime)
    index_seconds = source_index_seconds(
        Path(args.source_index_report).resolve() if args.source_index_report else None
    )
    timing_report = (
        Path(args.timing_report).resolve()
        if args.timing_report
        else output.with_suffix(".txt")
    )
    alternative_outcomes_path = (
        Path(args.alternative_outcomes).resolve()
        if args.alternative_outcomes
        else None
    )
    alternative_outcomes = read_alternative_outcomes(alternative_outcomes_path)
    try:
        write_processing_time_report(
            timing_report,
            finalized,
            output,
            index_seconds,
            analysis_seconds,
            placement_seconds,
            alternative_outcomes,
            alternative_outcomes_path,
        )
    except OSError as exc:
        warn_processing_time_report(timing_report, exc)
    print(
        f"SUMMARY|stage=finalize|shots={len(finalized['shots'])}|output={output}"
        f"|timingReport={timing_report}"
        f"|alternatives={len(alternative_outcomes)}"
        f"|seconds={index_seconds + analysis_seconds + placement_seconds:.3f}",
        flush=True,
    )
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except (FinalizeInputError, ContractValidationError, OSError, json.JSONDecodeError) as exc:
        print(f"ERROR|stage=finalize|message={str(exc).replace(chr(10), ' | ')}", file=sys.stderr, flush=True)
        raise SystemExit(2) from exc
