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

import argparse
import csv
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

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 ManifestInputError(ValueError):
    pass


REFERENCE_COLUMNS = (
    "referenceId",
    "mediaPath",
    "frameRate",
    "durationSeconds",
    "renderMode",
)
SOURCE_COLUMNS = (
    "sourceId",
    "mediaPath",
    "projectItemRef",
    "frameRate",
    "durationSeconds",
    "usableStartSeconds",
    "usableEndSeconds",
    "cacheAlgorithm",
    "cacheValue",
    "sizeBytes",
    "modifiedAtMs",
)
SHOT_COLUMNS = (
    "shotId",
    "timelineItemRef",
    "sequenceStartSeconds",
    "sequenceEndSeconds",
    "durationSeconds",
    "referenceStartSeconds",
    "referenceEndSeconds",
)


def read_table(path: Path, required_columns: tuple[str, ...]) -> list[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 len(fields) != len(set(fields)):
            raise ManifestInputError(f"{path.name} contains duplicate column names")
        missing = [name for name in required_columns if name not in fields]
        if missing:
            raise ManifestInputError(f"{path.name} is missing columns: {', '.join(missing)}")
        unexpected = [name for name in fields if name not in required_columns]
        if unexpected:
            raise ManifestInputError(f"{path.name} contains unexpected columns: {', '.join(unexpected)}")
        rows: list[dict[str, str]] = []
        for row_number, raw in enumerate(reader, start=2):
            extra = raw.pop("__extra__", None)
            if extra is not None:
                raise ManifestInputError(f"{path.name} row {row_number} contains extra fields")
            row = {name: str(value or "").strip() for name, value in raw.items() if name is not None}
            if any(row.values()):
                rows.append(row)
    return rows


def required_text(row: dict[str, str], name: str, context: str) -> str:
    value = row.get(name, "").strip()
    if not value:
        raise ManifestInputError(f"{context}.{name} must not be empty")
    return value


def number(row: dict[str, str], name: str, context: str, *, minimum: float = 0.0) -> float:
    raw = required_text(row, name, context)
    try:
        value = float(raw)
    except ValueError as exc:
        raise ManifestInputError(f"{context}.{name} must be a number") from exc
    if value < minimum:
        raise ManifestInputError(f"{context}.{name} must be >= {minimum}")
    return value


def optional_number(row: dict[str, str], name: str, context: str) -> float | None:
    raw = row.get(name, "").strip()
    if not raw:
        return None
    try:
        value = float(raw)
    except ValueError as exc:
        raise ManifestInputError(f"{context}.{name} must be a number") from exc
    if value < 0:
        raise ManifestInputError(f"{context}.{name} must be >= 0")
    return value


def ensure_unique(rows: list[dict[str, str]], field: str, table_name: str) -> None:
    values = [required_text(row, field, f"{table_name}[{index}]") for index, row in enumerate(rows)]
    if len(values) != len(set(values)):
        raise ManifestInputError(f"{table_name}.{field} values must be unique")


def fill_path_size_mtime(rows: list[dict[str, str]]) -> None:
    for index, row in enumerate(rows):
        if row.get("cacheAlgorithm", "").strip() != "PATH_SIZE_MTIME":
            continue
        if row.get("sizeBytes", "").strip() and row.get("modifiedAtMs", "").strip():
            continue
        media_path = Path(required_text(row, "mediaPath", f"sources[{index}]")).expanduser()
        try:
            stat = media_path.stat()
        except OSError as exc:
            raise ManifestInputError(
                f"sources[{index}] could not read PATH_SIZE_MTIME metadata for {media_path}: {exc}"
            ) from exc
        row["sizeBytes"] = str(stat.st_size)
        row["modifiedAtMs"] = str(stat.st_mtime_ns / 1_000_000)


def build_sources(source_rows: list[dict[str, str]]) -> list[dict[str, Any]]:
    if not source_rows:
        raise ManifestInputError("sources table must contain at least one data row")
    ensure_unique(source_rows, "sourceId", "sources")
    sources: list[dict[str, Any]] = []
    for index, row in enumerate(source_rows):
        context = f"sources[{index}]"
        duration = number(row, "durationSeconds", context)
        usable_start = number(row, "usableStartSeconds", context)
        usable_end = number(row, "usableEndSeconds", context)
        if usable_end <= usable_start:
            raise ManifestInputError(f"{context}.usableEndSeconds must be greater than usableStartSeconds")
        if usable_end > duration + 1e-6:
            raise ManifestInputError(f"{context}.usableEndSeconds must not exceed durationSeconds")
        cache_identity: dict[str, Any] = {
            "algorithm": required_text(row, "cacheAlgorithm", context),
            "value": required_text(row, "cacheValue", context),
        }
        size_bytes = optional_number(row, "sizeBytes", context)
        modified_at_ms = optional_number(row, "modifiedAtMs", context)
        if size_bytes is not None:
            cache_identity["sizeBytes"] = int(size_bytes)
        if modified_at_ms is not None:
            cache_identity["modifiedAtMs"] = modified_at_ms
        if cache_identity["algorithm"] == "PATH_SIZE_MTIME":
            if size_bytes is None or modified_at_ms is None:
                raise ManifestInputError(
                    f"{context}.sizeBytes and modifiedAtMs are required for PATH_SIZE_MTIME"
                )
        source: dict[str, Any] = {
            "sourceId": required_text(row, "sourceId", context),
            "mediaPath": required_text(row, "mediaPath", context),
            "frameRate": number(row, "frameRate", context, minimum=1e-9),
            "durationSeconds": duration,
            "usableRange": {"startSeconds": usable_start, "endSeconds": usable_end},
            "cacheIdentity": cache_identity,
        }
        project_item_ref = row.get("projectItemRef", "").strip()
        if project_item_ref:
            source["projectItemRef"] = project_item_ref
        sources.append(source)
    return sources


def build_manifest(reference_rows: list[dict[str, str]], source_rows: list[dict[str, str]], shot_rows: list[dict[str, str]]) -> dict[str, Any]:
    if len(reference_rows) != 1:
        raise ManifestInputError("reference table must contain exactly one data row")
    if not shot_rows:
        raise ManifestInputError("shots table must contain at least one data row")
    ensure_unique(shot_rows, "shotId", "shots")

    reference_row = reference_rows[0]
    reference = {
        "referenceId": required_text(reference_row, "referenceId", "reference"),
        "mediaPath": required_text(reference_row, "mediaPath", "reference"),
        "frameRate": number(reference_row, "frameRate", "reference", minimum=1e-9),
        "durationSeconds": number(reference_row, "durationSeconds", "reference"),
        "renderMode": required_text(reference_row, "renderMode", "reference"),
    }

    sources = build_sources(source_rows)

    shots: list[dict[str, Any]] = []
    for index, row in enumerate(shot_rows):
        context = f"shots[{index}]"
        sequence_start = number(row, "sequenceStartSeconds", context)
        sequence_end = number(row, "sequenceEndSeconds", context)
        duration = number(row, "durationSeconds", context)
        reference_start = number(row, "referenceStartSeconds", context)
        reference_end = number(row, "referenceEndSeconds", context)
        if sequence_end <= sequence_start or reference_end <= reference_start or duration <= 0:
            raise ManifestInputError(f"{context} must describe a positive sequence and reference range")
        sequence_duration = sequence_end - sequence_start
        reference_duration = reference_end - reference_start
        if abs(sequence_duration - duration) > 1e-4 or abs(reference_duration - duration) > 1e-4:
            raise ManifestInputError(f"{context}.durationSeconds must match both sequence and reference ranges")
        shot: dict[str, Any] = {
            "shotId": required_text(row, "shotId", context),
            "sequenceStartSeconds": sequence_start,
            "sequenceEndSeconds": sequence_end,
            "durationSeconds": duration,
            "referenceStartSeconds": reference_start,
            "referenceEndSeconds": reference_end,
            "sampleTimesSeconds": [],
            "analysisStatus": "PENDING",
            "candidates": [],
            "decision": "PENDING",
            "outcome": {"status": "NOT_PROCESSED"},
            "diagnostics": [],
        }
        timeline_item_ref = row.get("timelineItemRef", "").strip()
        if timeline_item_ref:
            shot["timelineItemRef"] = timeline_item_ref
        shots.append(shot)

    return {
        "formatVersion": "automation-agent-auto-conform/v1",
        "documentKind": "BENCHMARK_INPUT",
        "analysisVersion": "pending",
        "createdAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
        "reference": reference,
        "sources": sources,
        "shots": shots,
    }


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("--reference-table", required=True)
    parser.add_argument("--sources-table", required=True)
    parser.add_argument("--shots-table", required=True)
    parser.add_argument("--schema", required=True)
    parser.add_argument("--output", required=True)
    parser.add_argument("--fill-file-identities", action="store_true")
    args = parser.parse_args()

    output = Path(args.output).resolve()
    source_rows = read_table(Path(args.sources_table).resolve(), SOURCE_COLUMNS)
    if args.fill_file_identities:
        fill_path_size_mtime(source_rows)
    manifest = build_manifest(
        read_table(Path(args.reference_table).resolve(), REFERENCE_COLUMNS),
        source_rows,
        read_table(Path(args.shots_table).resolve(), SHOT_COLUMNS),
    )
    validate_contract(manifest, load_schema(Path(args.schema).resolve()))
    output.parent.mkdir(parents=True, exist_ok=True)
    output.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
    print(
        f"SUMMARY|stage=manifest|sources={len(manifest['sources'])}|shots={len(manifest['shots'])}|output={output}",
        flush=True,
    )
    return 0


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