#!/usr/bin/env python3
"""Manage nAvid single-video run state deterministically."""

from __future__ import annotations

import argparse
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path
import sys
import tempfile
from typing import Any


SCHEMA_VERSION = 1
STAGES = ("source", "script", "voice", "composition", "render", "qa")
STATUSES = {"pending", "in_progress", "complete", "invalid", "blocked", "failed"}
CHANGE_START = {
    "source": "source",
    "claim": "source",
    "script": "script",
    "narration": "voice",
    "voice": "voice",
    "provider": "voice",
    "template": "composition",
    "layout": "composition",
    "scene-plan": "composition",
}
MAX_ATTEMPTS = 3


def now_iso() -> str:
    return datetime.now(timezone.utc).replace(microsecond=0).isoformat()


def normalize_stage(stage: str) -> str:
    value = stage.lower()
    if value not in STAGES:
        raise ValueError(f"unknown stage: {stage}")
    return value


def state_path(project_dir: str) -> Path:
    return Path(project_dir).resolve() / "run-state.json"


def sha256_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for block in iter(lambda: handle.read(65536), b""):
            digest.update(block)
    return digest.hexdigest()


def file_record(project: Path, raw_path: str) -> dict[str, Any]:
    path = Path(raw_path)
    if not path.is_absolute():
        path = project / path
    path = path.resolve()
    if not path.is_file():
        raise FileNotFoundError(f"artifact not found: {path}")
    try:
        relative = path.relative_to(project).as_posix()
    except ValueError:
        relative = str(path)
    return {"path": relative, "sha256": sha256_file(path), "bytes": path.stat().st_size}


def parse_meta(items: list[str]) -> dict[str, str]:
    result: dict[str, str] = {}
    for item in items:
        if "=" not in item:
            raise ValueError(f"metadata must be key=value: {item}")
        key, value = item.split("=", 1)
        result[key] = value
    return result


def stage_record() -> dict[str, Any]:
    return {
        "status": "pending",
        "artifacts": [],
        "dependencies": [],
        "metadata": {},
        "attempts": [],
        "fallback": None,
        "updated_at": None,
    }


def emit_event(state: dict[str, Any], event_type: str, stage: str | None = None, **data: Any) -> None:
    event: dict[str, Any] = {"type": event_type, "timestamp": now_iso()}
    if stage:
        event["stage"] = stage
    event.update(data)
    state["events"].append(event)


def new_state(args: argparse.Namespace) -> dict[str, Any]:
    created = now_iso()
    state = {
        "schema_version": SCHEMA_VERSION,
        "project": {"name": Path(args.project_dir).resolve().name, "path": str(Path(args.project_dir).resolve())},
        "created_at": created,
        "updated_at": created,
        "intake": {
            "profile": args.profile,
            "language": args.language,
            "template_strategy": args.template_strategy,
            "template_set": args.template_set,
        },
        "template_selection": None,
        "mode": {"execution": args.mode, "reporting": args.reporting},
        "stages": {stage: stage_record() for stage in STAGES},
        "events": [],
    }
    emit_event(state, "intake_resolved", intake=state["intake"], mode=state["mode"])
    return state


def read_state(project_dir: str) -> dict[str, Any]:
    path = state_path(project_dir)
    if not path.is_file():
        raise FileNotFoundError(f"run state not found: {path}")
    with path.open("r", encoding="utf-8") as handle:
        state = json.load(handle)
    if state.get("schema_version") != SCHEMA_VERSION:
        raise ValueError(f"unsupported schema_version: {state.get('schema_version')}")
    return state


def write_state(project_dir: str, state: dict[str, Any]) -> None:
    path = state_path(project_dir)
    path.parent.mkdir(parents=True, exist_ok=True)
    state["updated_at"] = now_iso()
    fd, temp_name = tempfile.mkstemp(prefix="run-state-", suffix=".tmp", dir=str(path.parent))
    try:
        with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
            json.dump(state, handle, ensure_ascii=False, indent=2)
            handle.write("\n")
        os.replace(temp_name, path)
    finally:
        if os.path.exists(temp_name):
            os.unlink(temp_name)


def invalidate_from(state: dict[str, Any], start: str, reason: str) -> list[str]:
    start = normalize_stage(start)
    changed: list[str] = []
    for stage in STAGES[STAGES.index(start) :]:
        record = state["stages"][stage]
        if record["status"] != "pending" or stage == start:
            record["status"] = "invalid"
            record["updated_at"] = now_iso()
            changed.append(stage)
    emit_event(state, "stages_invalidated", start, reason=reason, stages=changed)
    return changed


def validate_record(project: Path, record: dict[str, Any]) -> str | None:
    for group in ("artifacts", "dependencies"):
        for item in record.get(group, []):
            path = Path(item["path"])
            if not path.is_absolute():
                path = project / path
            if not path.is_file():
                return f"{group[:-1]} missing: {item['path']}"
            if sha256_file(path) != item.get("sha256"):
                return f"{group[:-1]} fingerprint mismatch: {item['path']}"
    return None


def earliest_actionable(state: dict[str, Any]) -> dict[str, Any]:
    for stage in STAGES:
        status = state["stages"][stage]["status"]
        if status != "complete":
            return {"stage": stage, "status": status}
    return {"stage": None, "status": "complete"}


def output(value: dict[str, Any], human: bool = False) -> None:
    if human:
        project = value.get("project", {}).get("name", "-")
        resume = value.get("resume", {})
        print(f"Project: {project}")
        print(f"Next stage: {resume.get('stage') or 'none'} ({resume.get('status', '-')})")
        print(f"Events: {len(value.get('events', []))}")
        return
    print(json.dumps(value, ensure_ascii=False, indent=2))


def cmd_init(args: argparse.Namespace) -> dict[str, Any]:
    path = state_path(args.project_dir)
    if path.exists() and not args.force:
        raise FileExistsError(f"run state already exists: {path}")
    state = new_state(args)
    write_state(args.project_dir, state)
    state["resume"] = earliest_actionable(state)
    return state


def cmd_inspect(args: argparse.Namespace) -> dict[str, Any]:
    state = read_state(args.project_dir)
    state["resume"] = earliest_actionable(state)
    return state


def cmd_complete_stage(args: argparse.Namespace) -> dict[str, Any]:
    state = read_state(args.project_dir)
    stage = normalize_stage(args.stage)
    project = Path(args.project_dir).resolve()
    artifacts = [file_record(project, item) for item in args.artifact]
    dependencies = [file_record(project, item) for item in args.depends]
    metadata = parse_meta(args.meta)
    if stage == "qa" and (metadata.get("result") != "pass" or not dependencies):
        raise ValueError("qa completion requires --meta result=pass and a current render/composition dependency")
    record = state["stages"][stage]
    record.update(
        {
            "status": "complete",
            "artifacts": artifacts,
            "dependencies": dependencies,
            "metadata": metadata,
            "fallback": None,
            "updated_at": now_iso(),
        }
    )
    emit_event(state, "stage_completed", stage, artifacts=[x["path"] for x in artifacts])
    write_state(args.project_dir, state)
    state["resume"] = earliest_actionable(state)
    return state


def cmd_invalidate(args: argparse.Namespace) -> dict[str, Any]:
    state = read_state(args.project_dir)
    start = args.from_stage or CHANGE_START[args.change]
    changed = invalidate_from(state, start, args.reason or f"{args.change} changed")
    write_state(args.project_dir, state)
    return {"project": state["project"], "invalidated": changed, "resume": earliest_actionable(state), "events": state["events"]}


def cmd_select_template(args: argparse.Namespace) -> dict[str, Any]:
    state = read_state(args.project_dir)
    registry_path = Path(args.registry).resolve()
    registry = read_json_file(registry_path)
    entry = next((item for item in registry.get("sets", []) if item.get("id") == args.template_id), None)
    if not entry:
        raise ValueError(f"template not found in registry: {args.template_id}")
    if entry.get("tier") != "approved":
        raise ValueError(f"template is not approved: {args.template_id}")
    selected = {
        "id": entry["id"],
        "tier": entry["tier"],
        "domain": entry["domain"],
        "rationale": args.rationale,
        "selected_at": now_iso(),
    }
    previous = state.get("template_selection")
    invalidated: list[str] = []
    if previous and previous.get("id") != selected["id"]:
        invalidated = invalidate_from(state, "composition", "selected template changed")
    state["template_selection"] = selected
    state["intake"]["template_set"] = selected["id"]
    emit_event(
        state,
        "template_selected",
        template_id=selected["id"],
        tier=selected["tier"],
        domain=selected["domain"],
        rationale=selected["rationale"],
        changed=bool(previous and previous.get("id") != selected["id"]),
    )
    write_state(args.project_dir, state)
    return {"template_selection": selected, "invalidated": invalidated, "resume": earliest_actionable(state)}


def read_json_file(path: Path) -> dict[str, Any]:
    if not path.is_file():
        raise FileNotFoundError(f"registry not found: {path}")
    with path.open("r", encoding="utf-8") as handle:
        return json.load(handle)


def cmd_record_retry(args: argparse.Namespace) -> dict[str, Any]:
    if not args.same_output:
        raise ValueError("automatic retry requires --same-output")
    state = read_state(args.project_dir)
    stage = normalize_stage(args.stage)
    record = state["stages"][stage]
    attempt = {"timestamp": now_iso(), "error": args.error, "same_output": True}
    record["attempts"].append(attempt)
    exhausted = len(record["attempts"]) >= MAX_ATTEMPTS
    record["status"] = "failed" if exhausted else "in_progress"
    record["updated_at"] = now_iso()
    emit_event(state, "retry_exhausted" if exhausted else "technical_attempt_failed", stage, attempt_count=len(record["attempts"]), error=args.error)
    write_state(args.project_dir, state)
    return {"stage": stage, "attempt_count": len(record["attempts"]), "can_retry": not exhausted, "status": record["status"]}


def cmd_block_fallback(args: argparse.Namespace) -> dict[str, Any]:
    state = read_state(args.project_dir)
    stage = normalize_stage(args.stage)
    fallback = {
        "original": args.original,
        "proposal": args.proposal,
        "reason": args.reason,
        "impact": args.impact,
        "status": "pending_approval",
    }
    state["stages"][stage]["fallback"] = fallback
    state["stages"][stage]["status"] = "blocked"
    emit_event(state, "fallback_blocked", stage, fallback=fallback)
    write_state(args.project_dir, state)
    return {"stage": stage, "status": "blocked", "fallback": fallback}


def cmd_approve_fallback(args: argparse.Namespace) -> dict[str, Any]:
    state = read_state(args.project_dir)
    stage = normalize_stage(args.stage)
    record = state["stages"][stage]
    if record["status"] != "blocked" or not record.get("fallback"):
        raise ValueError(f"stage is not blocked by a fallback: {stage}")
    record["fallback"]["status"] = "approved"
    record["fallback"]["decision"] = args.decision
    record["fallback"]["scope"] = args.scope
    invalidated = invalidate_from(state, stage, "approved fallback requires regenerated downstream artifacts")
    emit_event(state, "fallback_approved", stage, decision=args.decision, scope=args.scope)
    write_state(args.project_dir, state)
    return {"stage": stage, "status": state["stages"][stage]["status"], "invalidated": invalidated, "decision": args.decision, "scope": args.scope}


def cmd_resume_check(args: argparse.Namespace) -> dict[str, Any]:
    state = read_state(args.project_dir)
    project = Path(args.project_dir).resolve()
    for stage in STAGES:
        record = state["stages"][stage]
        if record["status"] == "complete":
            reason = validate_record(project, record)
            if reason:
                invalidate_from(state, stage, reason)
                write_state(args.project_dir, state)
                break
    return {"project": state["project"], "resume": earliest_actionable(state), "stages": state["stages"], "events": state["events"]}


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__)
    commands = parser.add_subparsers(dest="command", required=True)

    init = commands.add_parser("init", help="Initialize project run-state.json after intake")
    init.add_argument("--project-dir", required=True)
    init.add_argument("--profile", required=True)
    init.add_argument("--language", required=True)
    init.add_argument("--template-strategy", required=True)
    init.add_argument("--template-set")
    init.add_argument("--mode", choices=("auto-run", "review-first"), default="auto-run")
    init.add_argument("--reporting", choices=("default", "quiet", "verbose"), default="default")
    init.add_argument("--force", action="store_true")
    init.set_defaults(handler=cmd_init)

    inspect = commands.add_parser("inspect", help="Inspect current run state")
    inspect.add_argument("--project-dir", required=True)
    inspect.add_argument("--human", action="store_true")
    inspect.set_defaults(handler=cmd_inspect)

    complete = commands.add_parser("complete-stage", help="Mark a stage complete with fingerprinted artifacts")
    complete.add_argument("--project-dir", required=True)
    complete.add_argument("--stage", required=True)
    complete.add_argument("--artifact", action="append", required=True)
    complete.add_argument("--depends", action="append", default=[])
    complete.add_argument("--meta", action="append", default=[])
    complete.set_defaults(handler=cmd_complete_stage)

    invalidate = commands.add_parser("invalidate", help="Invalidate a dependency cascade")
    invalidate.add_argument("--project-dir", required=True)
    invalidate.add_argument("--change", choices=tuple(CHANGE_START), default="script")
    invalidate.add_argument("--from-stage")
    invalidate.add_argument("--reason")
    invalidate.set_defaults(handler=cmd_invalidate)

    selection = commands.add_parser("select-template", help="Record a governed approved template choice")
    selection.add_argument("--project-dir", required=True)
    selection.add_argument("--registry", required=True)
    selection.add_argument("--template-id", required=True)
    selection.add_argument("--rationale", required=True)
    selection.set_defaults(handler=cmd_select_template)

    retry = commands.add_parser("record-retry", help="Record a same-output technical failure")
    retry.add_argument("--project-dir", required=True)
    retry.add_argument("--stage", required=True)
    retry.add_argument("--error", required=True)
    retry.add_argument("--same-output", action="store_true")
    retry.set_defaults(handler=cmd_record_retry)

    block = commands.add_parser("block-fallback", help="Block a quality-changing fallback proposal")
    block.add_argument("--project-dir", required=True)
    block.add_argument("--stage", required=True)
    block.add_argument("--original", required=True)
    block.add_argument("--proposal", required=True)
    block.add_argument("--reason", required=True)
    block.add_argument("--impact", required=True)
    block.set_defaults(handler=cmd_block_fallback)

    approve = commands.add_parser("approve-fallback", help="Approve a run-scoped fallback proposal")
    approve.add_argument("--project-dir", required=True)
    approve.add_argument("--stage", required=True)
    approve.add_argument("--decision", required=True)
    approve.add_argument("--scope", required=True)
    approve.set_defaults(handler=cmd_approve_fallback)

    resume = commands.add_parser("resume-check", help="Validate completed artifacts and locate resume stage")
    resume.add_argument("--project-dir", required=True)
    resume.set_defaults(handler=cmd_resume_check)
    return parser


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()
    try:
        result = args.handler(args)
        output(result, getattr(args, "human", False))
        return 0
    except (ValueError, FileNotFoundError, FileExistsError, KeyError, json.JSONDecodeError) as exc:
        print(json.dumps({"status": "error", "error": str(exc)}, ensure_ascii=False))
        return 1


if __name__ == "__main__":
    sys.exit(main())
