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

import argparse
import json
import os
import shutil
import sys
import time
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 auto_conform_benchmark import (
    DEFAULT_INDEX_CHUNK_SECONDS,
    DESCRIPTOR_VERSION,
    AutoConformFailure,
    atomic_write_json,
    build_source_index,
    build_source_index_chunk,
    directory_size,
    finalize_source_index,
    legacy_source_cache_path,
    peak_memory_bytes,
    plan_source_index,
    progress,
    prune_source_cache,
    source_cache_metadata,
    source_cache_path,
)
from auto_conform_manifest import (
    SOURCE_COLUMNS,
    ManifestInputError,
    build_sources,
    fill_path_size_mtime,
    read_table,
)


REPORT_FORMAT = "automation-agent-auto-conform-source-index-report/v1"


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 processing_time_report_path(output_path: Path, requested_path: str | None) -> Path:
    return Path(requested_path).resolve() if requested_path else output_path.with_suffix(".txt")


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 write_processing_time_report(path: Path, report: dict[str, Any], json_path: Path) -> None:
    total_seconds = float(report["timing"]["totalSeconds"])
    lines = [
        "Automation Agent - Build Auto-Conform Source Index",
        "",
        f"Status: {report['status']}",
        f"Completed: {report['createdAt']}",
        f"Processing time: {format_duration(total_seconds)}",
        f"Processing time seconds: {total_seconds:.3f}",
        f"Sources processed: {report['sourceCount']}",
        f"Existing indexes reused: {report['cacheHits']}",
        f"Indexes built or rebuilt: {report['builtOrRebuilt']}",
        f"Workspace: {report['workspacePath']}",
        f"Detailed JSON report: {json_path}",
        "",
    ]
    atomic_write_text(path, "\n".join(lines))


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 selected_cache_paths(
    sources: list[dict[str, Any]],
    cache_dir: Path,
    interval: float,
    chunk_seconds: float,
) -> set[Path]:
    paths: set[Path] = set()
    for source in sources:
        paths.add(
            source_cache_path(
                cache_dir,
                source_cache_metadata(source, interval, chunk_seconds),
            ).resolve()
        )
        paths.add(legacy_source_cache_path(cache_dir, source, interval).resolve())
    return paths


def remove_selected_indexes(paths: set[Path]) -> int:
    removed = 0
    for path in sorted(paths, key=str):
        if path.is_dir():
            shutil.rmtree(path)
            removed += 1
        elif path.is_file():
            path.unlink()
            removed += 1
    return removed


def source_report(source: dict[str, Any], index: dict[str, Any]) -> dict[str, Any]:
    return {
        "sourceId": source["sourceId"],
        "projectItemRef": source.get("projectItemRef", ""),
        "mediaPath": source["mediaPath"],
        "usableRange": source["usableRange"],
        "sampleCount": len(index["times"]),
        "chunkCount": len(index["chunks"]),
        "cacheHit": bool(index["cacheHit"]),
        "cachePath": index["cachePath"],
        "legacyCacheRemoved": bool(index["legacyCacheRemoved"]),
    }


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument(
        "--operation",
        choices=["ALL", "PLAN", "BUILD_CHUNK", "FINALIZE"],
        default="ALL",
    )
    parser.add_argument("--sources-table", required=True)
    parser.add_argument("--ffmpeg", required=True)
    parser.add_argument("--output", required=True)
    parser.add_argument("--timing-report")
    parser.add_argument("--work-dir", required=True)
    parser.add_argument("--cache-dir", required=True)
    parser.add_argument("--sample-interval-seconds", type=float, default=0.5)
    parser.add_argument("--index-ffmpeg-timeout-seconds", type=float, default=3600.0)
    parser.add_argument("--index-chunk-seconds", type=float, default=DEFAULT_INDEX_CHUNK_SECONDS)
    parser.add_argument("--cache-max-bytes", type=int, default=10 * 1024 * 1024 * 1024)
    parser.add_argument("--rebuild-selected", choices=["true", "false"], default="false")
    parser.add_argument("--keep-work-dir", choices=["true", "false"], default="false")
    parser.add_argument("--tasks-output")
    parser.add_argument("--plan-output")
    parser.add_argument("--source-index", type=int)
    parser.add_argument("--chunk-index", type=int)
    args = parser.parse_args()

    if args.sample_interval_seconds <= 0:
        raise ValueError("--sample-interval-seconds must be greater than zero")
    if args.index_ffmpeg_timeout_seconds <= 0:
        raise ValueError("--index-ffmpeg-timeout-seconds must be greater than zero")
    if args.index_chunk_seconds <= 0:
        raise ValueError("--index-chunk-seconds must be greater than zero")
    if args.cache_max_bytes <= 0:
        raise ValueError("--cache-max-bytes must be greater than zero")

    started = time.perf_counter()
    started_epoch_seconds = time.time()
    sources_table = Path(args.sources_table).resolve()
    output_path = Path(args.output).resolve()
    work_dir = Path(args.work_dir).resolve()
    cache_dir = Path(args.cache_dir).resolve()
    source_rows = read_table(sources_table, SOURCE_COLUMNS)
    fill_path_size_mtime(source_rows)
    sources = build_sources(source_rows)
    protected_paths = selected_cache_paths(
        sources,
        cache_dir,
        args.sample_interval_seconds,
        args.index_chunk_seconds,
    )

    work_dir.mkdir(parents=True, exist_ok=True)
    cache_dir.mkdir(parents=True, exist_ok=True)

    if args.operation == "BUILD_CHUNK":
        if args.source_index is None or args.chunk_index is None:
            raise ValueError("BUILD_CHUNK requires --source-index and --chunk-index")
        if args.source_index < 0 or args.source_index >= len(sources):
            raise ValueError("--source-index is outside the selected source list")
        source = sources[args.source_index]
        build_source_index_chunk(
            sources_table,
            source,
            args.ffmpeg,
            work_dir,
            args.sample_interval_seconds,
            args.index_ffmpeg_timeout_seconds,
            cache_dir,
            args.chunk_index,
            args.index_chunk_seconds,
        )
        print(
            "SUMMARY|stage=source-index-chunk"
            f"|source={args.source_index + 1}"
            f"|sources={len(sources)}"
            f"|chunk={args.chunk_index + 1}",
            flush=True,
        )
        return 0

    removed_selected = 0
    if args.operation in {"ALL", "PLAN"} and args.rebuild_selected == "true":
        removed_selected = remove_selected_indexes(protected_paths)
        progress("cache-rebuild-selected", sources=len(sources), removed=removed_selected)

    prune_before = {"remainingBytes": 0, "removedBytes": 0, "removedFiles": 0}
    if args.operation in {"ALL", "PLAN"}:
        prune_before = prune_source_cache(cache_dir, args.cache_max_bytes, protected_paths)
        progress("cache-prune", phase="before-index", **prune_before)

    if args.operation == "PLAN":
        if not args.tasks_output or not args.plan_output:
            raise ValueError("PLAN requires --tasks-output and --plan-output")
        tasks_path = Path(args.tasks_output).resolve()
        plan_path = Path(args.plan_output).resolve()
        task_lines: list[str] = []
        source_plans: list[dict[str, Any]] = []
        for source_index, source in enumerate(sources):
            source_plan = plan_source_index(
                sources_table,
                source,
                work_dir,
                args.sample_interval_seconds,
                cache_dir,
                args.index_chunk_seconds,
            )
            source_plans.append(
                {
                    "sourceId": source["sourceId"],
                    "cacheHit": bool(source_plan["cacheComplete"]),
                    "chunkCount": len(source_plan["chunks"]),
                    "missingChunks": source_plan["missingChunks"],
                }
            )
            for chunk_index in source_plan["missingChunks"]:
                task_lines.append(
                    "\t".join(
                        [
                            str(source_index),
                            str(chunk_index),
                            str(source_index + 1),
                            str(len(sources)),
                            str(int(chunk_index) + 1),
                            str(len(source_plan["chunks"])),
                            str(source["sourceId"]),
                        ]
                    )
                )
        tasks_path.parent.mkdir(parents=True, exist_ok=True)
        tasks_path.write_text("\n".join(task_lines) + ("\n" if task_lines else ""), encoding="utf-8")
        atomic_write_json(
            plan_path,
            {
                "format": "automation-agent-auto-conform-source-index-plan/v1",
                "createdAt": utc_now(),
                "startedEpochSeconds": started_epoch_seconds,
                "sourceCount": len(sources),
                "removedSelectedIndexes": removed_selected,
                "cachePruneBefore": prune_before,
                "sources": source_plans,
                "taskCount": len(task_lines),
            },
        )
        progress(
            "plan-complete",
            sources=len(sources),
            tasks=len(task_lines),
            cacheHits=sum(1 for item in source_plans if item["cacheHit"]),
        )
        print(
            "SUMMARY|stage=source-index-plan"
            f"|sources={len(sources)}"
            f"|tasks={len(task_lines)}"
            f"|tasksOutput={tasks_path}"
            f"|planOutput={plan_path}",
            flush=True,
        )
        return 0

    plan_document: dict[str, Any] | None = None
    if args.operation == "FINALIZE":
        if not args.plan_output:
            raise ValueError("FINALIZE requires --plan-output")
        plan_document = json.loads(Path(args.plan_output).resolve().read_text(encoding="utf-8"))
        if (
            plan_document.get("format")
            != "automation-agent-auto-conform-source-index-plan/v1"
            or int(plan_document.get("sourceCount", -1)) != len(sources)
        ):
            raise ValueError("source-index plan does not match the selected source list")

    indexed_sources: list[dict[str, Any]] = []
    for source_number, source in enumerate(sources, start=1):
        progress(
            "index",
            source=source_number,
            total=len(sources),
            sourceId=source["sourceId"],
        )
        if args.operation == "FINALIZE":
            planned_source = plan_document["sources"][source_number - 1]
            if planned_source.get("sourceId") != source["sourceId"]:
                raise ValueError("source-index plan source order no longer matches")
            index = finalize_source_index(
                sources_table,
                source,
                work_dir,
                args.sample_interval_seconds,
                cache_dir,
                args.index_chunk_seconds,
                cache_hit=bool(planned_source.get("cacheHit")),
            )
        else:
            index = build_source_index(
                sources_table,
                source,
                args.ffmpeg,
                work_dir,
                args.sample_interval_seconds,
                args.index_ffmpeg_timeout_seconds,
                cache_dir,
                args.index_chunk_seconds,
            )
        indexed_sources.append(source_report(source, index))
        progress(
            "index-complete",
            source=source_number,
            total=len(sources),
            sourceId=source["sourceId"],
            cacheHit=str(bool(index["cacheHit"])).lower(),
            samples=len(index["times"]),
        )

    prune_after = prune_source_cache(cache_dir, args.cache_max_bytes, protected_paths)
    progress("cache-prune", phase="after-index", **prune_after)
    cache_hits = sum(1 for source in indexed_sources if source["cacheHit"])
    total_seconds = (
        max(0.0, time.time() - float(plan_document["startedEpochSeconds"]))
        if plan_document is not None
        else time.perf_counter() - started
    )
    report = {
        "format": REPORT_FORMAT,
        "status": "COMPLETED",
        "createdAt": utc_now(),
        "workspacePath": str(cache_dir.parent),
        "cachePath": str(cache_dir),
        "descriptorVersion": DESCRIPTOR_VERSION,
        "samplingIntervalSeconds": args.sample_interval_seconds,
        "indexChunkSeconds": args.index_chunk_seconds,
        "sourceCount": len(indexed_sources),
        "cacheHits": cache_hits,
        "builtOrRebuilt": len(indexed_sources) - cache_hits,
        "removedSelectedIndexes": (
            int(plan_document.get("removedSelectedIndexes", 0))
            if plan_document is not None
            else removed_selected
        ),
        "sources": indexed_sources,
        "cachePrune": {
            "before": (
                plan_document.get("cachePruneBefore", prune_before)
                if plan_document is not None
                else prune_before
            ),
            "after": prune_after,
        },
        "timing": {
            "totalSeconds": total_seconds,
            **(
                {
                    "elapsedSincePlanSeconds": total_seconds,
                    "measurement": "WALL_CLOCK_SINCE_PLAN",
                }
                if plan_document is not None
                else {"measurement": "PROCESS_ELAPSED"}
            ),
        },
        "peakMemoryBytes": peak_memory_bytes(),
        "temporaryDiskBytes": directory_size(work_dir),
    }
    timing_report_path = processing_time_report_path(output_path, args.timing_report)
    report["processingTimeReportPath"] = str(timing_report_path)
    atomic_write_json(output_path, report)
    try:
        write_processing_time_report(timing_report_path, report, output_path)
    except OSError as exc:
        warn_processing_time_report(timing_report_path, exc)
    progress("complete", sources=len(indexed_sources), cacheHits=cache_hits, output=output_path)
    print(
        "SUMMARY|stage=source-index"
        f"|sources={len(indexed_sources)}"
        f"|cacheHits={cache_hits}"
        f"|built={len(indexed_sources) - cache_hits}"
        f"|output={output_path}"
        f"|timingReport={timing_report_path}"
        f"|seconds={total_seconds:.3f}",
        flush=True,
    )
    if args.keep_work_dir != "true":
        shutil.rmtree(work_dir, ignore_errors=True)
    return 0


def argument_value(name: str) -> str | None:
    try:
        index = sys.argv.index(name)
    except ValueError:
        return None
    return sys.argv[index + 1] if index + 1 < len(sys.argv) else None


def structured_error_value(value: object) -> str:
    return str(value).replace("|", "/").replace("\r", " ").replace("\n", " ")


def cli() -> int:
    try:
        return main()
    except AutoConformFailure as exc:
        failure = exc
    except (ManifestInputError, OSError, ValueError, json.JSONDecodeError) as exc:
        failure = AutoConformFailure(
            "source-index",
            "SOURCE_INDEX_FAILED",
            str(exc) or exc.__class__.__name__,
            {"exceptionType": exc.__class__.__name__},
        )
    except Exception as exc:
        failure = AutoConformFailure(
            "source-index",
            "UNEXPECTED_FAILURE",
            str(exc) or exc.__class__.__name__,
            {"exceptionType": exc.__class__.__name__},
        )

    output_value = argument_value("--output")
    report_path: Path | None = None
    if output_value:
        report_path = Path(output_value).resolve()
        try:
            atomic_write_json(
                report_path,
                {
                    "format": REPORT_FORMAT,
                    "status": "FAILED",
                    "createdAt": utc_now(),
                    **failure.record(),
                },
            )
        except OSError:
            report_path = None
    fields = [
        "ERROR",
        f"stage={structured_error_value(failure.stage)}",
        f"code={structured_error_value(failure.code)}",
        f"message={structured_error_value(failure)}",
    ]
    if report_path is not None:
        fields.append(f"report={structured_error_value(report_path)}")
    for key, value in failure.details.items():
        fields.append(f"{key}={structured_error_value(value)}")
    print("|".join(fields), file=sys.stderr, flush=True)
    return 2


if __name__ == "__main__":
    raise SystemExit(cli())
