#!/usr/bin/env python3
"""Durable, privacy-safe live progress for every user-invoked CARBON command."""

from __future__ import annotations

import argparse
import datetime as dt
import http.server
import json
import os
import re
import secrets
import signal
import tempfile
import time
from pathlib import Path
from typing import Any
from urllib.parse import urlparse


SCHEMA = "carbon.command-progress/v1"
STATUSES = {"planned", "running", "completed", "partial", "blocked", "failed", "canceled"}
STEP_STATUSES = {"planned", "running", "completed", "blocked", "failed", "skipped"}
TERMINAL_STEPS = {"completed", "blocked", "failed", "skipped"}
TERMINAL_STATUSES = {"completed", "partial", "blocked", "failed", "canceled"}
SAFE_ID = re.compile(r"^[a-z0-9][a-z0-9-]{7,100}$")
SECRET_PATTERNS = [
    re.compile(r"\bsk-[A-Za-z0-9_-]{16,}\b"),
    re.compile(r"\bghp_[A-Za-z0-9]{20,}\b"),
    re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
    re.compile(r"\bBearer\s+[A-Za-z0-9._~+/-]{16,}\b", re.IGNORECASE),
    re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
]


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


def clipped(value: Any, limit: int = 600) -> str:
    text = " ".join(str(value or "").split())
    for pattern in SECRET_PATTERNS:
        text = pattern.sub("[REDACTED]", text)
    return text if len(text) <= limit else text[: limit - 1] + "…"


def safe_root(value: str) -> Path:
    lexical = Path(value).expanduser()
    if lexical.is_symlink():
        raise ValueError("root must be an existing non-symlink project directory")
    root = lexical.resolve()
    if not root.is_dir():
        raise ValueError("root must be an existing non-symlink project directory")
    return root


def progress_dir(root: Path) -> Path:
    folder = root / ".carbon" / "reports" / "live" / "commands"
    folder.mkdir(parents=True, exist_ok=True, mode=0o700)
    return folder


def state_path(root: Path, progress_id: str) -> Path:
    if not SAFE_ID.fullmatch(progress_id):
        raise ValueError("invalid CARBON command progress ID")
    return progress_dir(root) / f"{progress_id}.json"


def read_state(path: Path) -> dict[str, Any]:
    if path.is_symlink() or not path.is_file():
        raise ValueError("CARBON command progress state does not exist")
    value = json.loads(path.read_text(encoding="utf-8"))
    if not isinstance(value, dict) or value.get("schema") != SCHEMA:
        raise ValueError("invalid CARBON command progress state")
    return value


def write_state(path: Path, value: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
    with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
        json.dump(value, handle, indent=2, ensure_ascii=False)
        handle.write("\n")
        temporary = Path(handle.name)
    os.chmod(temporary, 0o600)
    os.replace(temporary, path)


def normalize_command(value: Any) -> str:
    return clipped(value, 120).lower().lstrip("/$").replace("_", "-")


def command_match_key(value: Any) -> str:
    normalized = normalize_command(value)
    return normalized[7:] if normalized.startswith("carbon-") else normalized


def parse_payload(raw: str | None) -> dict[str, Any]:
    try:
        value = json.loads(raw or "{}")
    except json.JSONDecodeError as error:
        raise ValueError("payloadJson must contain valid JSON") from error
    if not isinstance(value, dict):
        raise ValueError("payloadJson must contain an object")
    return value


def normalize_steps(raw: Any) -> list[dict[str, Any]]:
    if not isinstance(raw, list) or not raw:
        raise ValueError("Start progress with one to twelve high-level steps")
    if len(raw) > 12:
        raise ValueError("Command progress supports at most twelve high-level steps")
    seen: set[str] = set()
    steps: list[dict[str, Any]] = []
    for index, item in enumerate(raw):
        source = item if isinstance(item, dict) else {"title": item}
        step_id = clipped(source.get("id") or f"step-{index + 1}", 80).lower()
        step_id = re.sub(r"[^a-z0-9]+", "-", step_id).strip("-") or f"step-{index + 1}"
        if step_id in seen:
            step_id = f"{step_id}-{index + 1}"
        seen.add(step_id)
        title = clipped(source.get("title") or source.get("label"), 240)
        if not title:
            raise ValueError("Every command-progress step needs a title")
        status = clipped(source.get("status") or ("running" if index == 0 else "planned"), 20).lower()
        if status not in STEP_STATUSES:
            status = "planned"
        steps.append({
            "id": step_id,
            "title": title,
            "detail": clipped(source.get("detail"), 800),
            "status": status,
            "startedAt": now() if status == "running" else "",
            "completedAt": now() if status in TERMINAL_STEPS else "",
            "summary": clipped(source.get("summary"), 1000),
            "why": clipped(source.get("why"), 700),
            "risk": clipped(source.get("risk"), 120),
        })
    return steps


def metrics(raw: Any) -> list[dict[str, str]]:
    if not isinstance(raw, list):
        return []
    values: list[dict[str, str]] = []
    for item in raw[:12]:
        if not isinstance(item, dict):
            continue
        label = clipped(item.get("label"), 100)
        value = clipped(item.get("value"), 160)
        if label and value:
            values.append({"label": label, "value": value, "detail": clipped(item.get("detail"), 300)})
    return values


def confidence(raw: Any) -> dict[str, Any]:
    if not isinstance(raw, dict):
        return {}
    value: dict[str, Any] = {
        "label": clipped(raw.get("label"), 100),
        "basis": clipped(raw.get("basis"), 700),
    }
    for key in ("start", "current"):
        candidate = raw.get(key)
        if isinstance(candidate, (int, float)) and not isinstance(candidate, bool):
            value[key] = max(0, min(100, round(float(candidate))))
    if "current" in value and "start" in value:
        value["delta"] = value["current"] - value["start"]
    return value if any(item not in {"", None} for item in value.values()) else {}


def risks(raw: Any) -> list[dict[str, str]]:
    if not isinstance(raw, list):
        return []
    values: list[dict[str, str]] = []
    for index, item in enumerate(raw[:8]):
        if not isinstance(item, dict):
            continue
        label = clipped(item.get("label") or item.get("title"), 180)
        if not label:
            continue
        severity = clipped(item.get("severity") or "medium", 20).lower()
        if severity not in {"low", "medium", "high", "critical"}:
            severity = "medium"
        status = clipped(item.get("status") or "open", 20).lower()
        if status not in {"open", "testing", "mitigated", "unknown"}:
            status = "open"
        values.append({
            "id": clipped(item.get("id") or f"risk-{index + 1}", 80),
            "label": label,
            "detail": clipped(item.get("detail"), 500),
            "severity": severity,
            "status": status,
        })
    return values


def evidence(root: Path, raw: Any) -> list[dict[str, Any]]:
    if not isinstance(raw, list):
        return []
    values: list[dict[str, Any]] = []
    carbon_root = (root / ".carbon").resolve()
    allowed_images = {".png", ".jpg", ".jpeg", ".webp"}
    for index, item in enumerate(raw[:12]):
        if not isinstance(item, dict):
            continue
        label = clipped(item.get("label") or item.get("title"), 180)
        if not label:
            continue
        item_id = clipped(item.get("id") or f"evidence-{index + 1}", 80).lower()
        item_id = re.sub(r"[^a-z0-9]+", "-", item_id).strip("-") or f"evidence-{index + 1}"
        status = clipped(item.get("status") or "captured", 20).lower()
        if status not in {"captured", "observed", "passed", "failed", "withheld", "unavailable"}:
            status = "captured"
        entry: dict[str, Any] = {
            "id": item_id,
            "label": label,
            "detail": clipped(item.get("detail"), 600),
            "type": clipped(item.get("type") or "evidence", 40).lower(),
            "status": status,
            "safeToShow": item.get("safeToShow") is True,
        }
        image_value = item.get("imagePath") or item.get("path")
        if image_value and entry["safeToShow"]:
            lexical_image = Path(str(image_value)).expanduser()
            lexical_image = lexical_image if lexical_image.is_absolute() else root / lexical_image
            if lexical_image.is_symlink():
                raise ValueError("Command-progress evidence must be an existing non-symlink PNG, JPEG, or WebP image")
            image = lexical_image.resolve()
            try:
                image.relative_to(carbon_root)
            except ValueError as error:
                raise ValueError("Command-progress evidence images must remain inside the project .carbon directory") from error
            if image.is_symlink() or not image.is_file() or image.suffix.lower() not in allowed_images:
                raise ValueError("Command-progress evidence must be an existing non-symlink PNG, JPEG, or WebP image")
            if image.stat().st_size > 12 * 1024 * 1024:
                raise ValueError("Command-progress evidence images must be 12 MB or smaller")
            entry["imagePath"] = image.relative_to(root).as_posix()
            entry["imageAvailable"] = True
        values.append(entry)
    return values


def enrich(state: dict[str, Any]) -> dict[str, Any]:
    steps = state.get("steps") if isinstance(state.get("steps"), list) else []
    counts = {name: 0 for name in sorted(STEP_STATUSES)}
    for step in steps:
        status = str(step.get("status") or "planned")
        counts[status if status in counts else "planned"] += 1
    completed = sum(counts[name] for name in TERMINAL_STEPS)
    total = len(steps)
    state["progress"] = {
        "completed": completed,
        "total": total,
        "percent": round(completed / total * 100) if total else 0,
        "counts": counts,
    }
    state["completedSteps"] = [
        {"id": step.get("id"), "title": step.get("title"), "status": step.get("status"), "summary": step.get("summary")}
        for step in steps if step.get("status") in TERMINAL_STEPS
    ]
    active = next((step for step in steps if step.get("status") == "running"), None)
    if active is None and steps:
        active = next((step for step in reversed(steps) if step.get("status") in TERMINAL_STEPS), steps[0])
    state["currentAction"] = {
        "id": active.get("id"),
        "title": active.get("title"),
        "detail": active.get("detail"),
        "why": active.get("why"),
        "summary": active.get("summary"),
        "risk": active.get("risk"),
        "status": active.get("status"),
    } if active else {}
    state["confidence"] = confidence(state.get("confidence"))
    return state


def start(root: Path, payload: dict[str, Any]) -> dict[str, Any]:
    command = normalize_command(payload.get("command"))
    if not command:
        raise ValueError("command is required")
    timestamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    progress_id = f"{timestamp.lower()}-{secrets.token_hex(6)}"
    created = now()
    state = {
        "schema": SCHEMA,
        "progressId": progress_id,
        "project": clipped(root.name, 160) or "project",
        "command": command,
        "title": clipped(payload.get("title") or f"Running /{command}", 300),
        "scope": clipped(payload.get("scope") or "Current project and requested context", 1200),
        "status": "running",
        "phase": clipped(payload.get("phase") or "Starting", 120),
        "summary": clipped(payload.get("summary") or "CARBON is starting this workflow.", 1600),
        "reason": clipped(payload.get("reason") or "CARBON is selecting the most useful next evidence for this request.", 1200),
        "createdAt": created,
        "updatedAt": created,
        "steps": normalize_steps(payload.get("steps")),
        "metrics": metrics(payload.get("metrics")),
        "confidence": confidence(payload.get("confidence")),
        "risks": risks(payload.get("risks")),
        "evidence": evidence(root, payload.get("evidence")),
        "reportPath": "",
        "evidenceBoundary": clipped(payload.get("evidenceBoundary") or "Progress reflects only durable steps explicitly recorded by this CARBON command.", 1200),
    }
    file = state_path(root, progress_id)
    write_state(file, enrich(state))
    return {"progressId": progress_id, "statePath": str(file), "progress": enrich(state)}


def apply_update(state: dict[str, Any], payload: dict[str, Any], root: Path) -> dict[str, Any]:
    requested_status = clipped(payload.get("status"), 20).lower()
    if requested_status:
        if requested_status not in STATUSES:
            raise ValueError("Unsupported command progress status")
        state["status"] = requested_status
    if payload.get("phase") is not None:
        state["phase"] = clipped(payload.get("phase"), 120)
    if payload.get("summary") is not None:
        state["summary"] = clipped(payload.get("summary"), 1600)
    if payload.get("reason") is not None:
        state["reason"] = clipped(payload.get("reason"), 1200)
    if payload.get("reportPath") is not None:
        report = Path(str(payload.get("reportPath") or "")).expanduser().resolve()
        carbon_root = (root / ".carbon").resolve()
        try:
            relative_report = report.relative_to(carbon_root)
        except ValueError as error:
            raise ValueError("reportPath must remain inside the project .carbon directory") from error
        state["reportPath"] = f".carbon/{relative_report.as_posix()}"
    if payload.get("metrics") is not None:
        state["metrics"] = metrics(payload.get("metrics"))
    if payload.get("confidence") is not None:
        state["confidence"] = confidence(payload.get("confidence"))
    if payload.get("risks") is not None:
        state["risks"] = risks(payload.get("risks"))
    if payload.get("evidence") is not None:
        state["evidence"] = evidence(root, payload.get("evidence"))
    if payload.get("evidenceBoundary") is not None:
        state["evidenceBoundary"] = clipped(payload.get("evidenceBoundary"), 1200)
    updates = payload.get("steps") if isinstance(payload.get("steps"), list) else []
    by_id = {str(step.get("id")): step for step in state.get("steps", [])}
    for raw in updates[:12]:
        if not isinstance(raw, dict):
            continue
        step = by_id.get(clipped(raw.get("id"), 80).lower())
        if not step:
            raise ValueError(f"Unknown command-progress step: {raw.get('id')}")
        status = clipped(raw.get("status"), 20).lower()
        if status:
            if status not in STEP_STATUSES:
                raise ValueError("Unsupported command-progress step status")
            if status == "running" and not step.get("startedAt"):
                step["startedAt"] = now()
            if status in TERMINAL_STEPS:
                step["completedAt"] = now()
            step["status"] = status
        if raw.get("detail") is not None:
            step["detail"] = clipped(raw.get("detail"), 800)
        if raw.get("summary") is not None:
            step["summary"] = clipped(raw.get("summary"), 1000)
        if raw.get("why") is not None:
            step["why"] = clipped(raw.get("why"), 700)
        if raw.get("risk") is not None:
            step["risk"] = clipped(raw.get("risk"), 120)
    if payload.get("advance") is True and state.get("status") == "running":
        if not any(step.get("status") == "running" for step in state.get("steps", [])):
            next_step = next((step for step in state.get("steps", []) if step.get("status") == "planned"), None)
            if next_step:
                next_step["status"] = "running"
                next_step["startedAt"] = now()
    if state.get("status") in TERMINAL_STATUSES:
        running_terminal = {
            "completed": "completed",
            "blocked": "blocked",
            "failed": "failed",
            "partial": "skipped",
            "canceled": "skipped",
        }[state["status"]]
        for step in state.get("steps", []):
            if step.get("status") in TERMINAL_STEPS:
                continue
            step["status"] = running_terminal if step.get("status") == "running" else (
                "completed" if state["status"] == "completed" else "skipped"
            )
            step["completedAt"] = now()
    state["updatedAt"] = now()
    return enrich(state)


def update(root: Path, progress_id: str, payload: dict[str, Any]) -> dict[str, Any]:
    file = state_path(root, progress_id)
    state = apply_update(read_state(file), payload, root)
    write_state(file, state)
    return {"progressId": progress_id, "statePath": str(file), "progress": state}


def latest(root: Path, command: str) -> tuple[Path, dict[str, Any]] | None:
    target = command_match_key(command)
    matches: list[tuple[str, Path, dict[str, Any]]] = []
    for file in progress_dir(root).glob("*.json"):
        try:
            state = read_state(file)
        except (OSError, ValueError, json.JSONDecodeError):
            continue
        if command_match_key(state.get("command")) == target and state.get("status") in {"planned", "running"}:
            matches.append((str(state.get("updatedAt") or state.get("createdAt") or ""), file, state))
    if not matches:
        return None
    _, file, state = max(matches, key=lambda item: item[0])
    return file, state


def complete_latest(root: Path, command: str, payload: dict[str, Any]) -> dict[str, Any]:
    found = latest(root, command)
    if not found:
        return {"matched": False, "command": normalize_command(command)}
    file, state = found
    state = apply_update(state, payload, root)
    write_state(file, state)
    return {"matched": True, "progressId": state["progressId"], "statePath": str(file), "progress": state}


def serve(root: Path, progress_id: str, ready_value: str, ttl_seconds: int) -> int:
    file = state_path(root, progress_id)
    read_state(file)
    ready_path = Path(ready_value).expanduser().resolve()
    runtime_root = (root / ".carbon" / "runtime").resolve()
    runtime_root.mkdir(parents=True, exist_ok=True, mode=0o700)
    try:
        ready_path.relative_to(runtime_root)
    except ValueError as error:
        raise ValueError("readyFile must remain inside CARBON runtime state") from error
    token = os.environ.get("CARBON_COMMAND_VIEW_TOKEN", "")
    if len(token) < 32:
        raise ValueError("CARBON command viewer requires an ephemeral local access token")
    plugin_root = Path(__file__).resolve().parents[3]
    ui_path = plugin_root / "ui" / "command-progress.html"
    if ui_path.is_symlink() or not ui_path.is_file():
        raise ValueError("CARBON command-progress UI is unavailable")
    ui = ui_path.read_bytes()
    instance_id = secrets.token_urlsafe(18)
    started = time.monotonic()
    stop = False

    class Handler(http.server.BaseHTTPRequestHandler):
        server_version = "CARBONCommandViewer/1"

        def log_message(self, _format: str, *_args: Any) -> None:
            return

        def local_request(self) -> bool:
            host = str(self.headers.get("Host") or "").split(":", 1)[0].strip("[]").lower()
            return host in {"127.0.0.1", "localhost", "::1"}

        def json_response(self, status: int, value: dict[str, Any]) -> None:
            body = json.dumps(value).encode("utf-8")
            self.send_response(status)
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.send_header("Content-Length", str(len(body)))
            self.send_header("Cache-Control", "no-store")
            self.send_header("X-Content-Type-Options", "nosniff")
            self.end_headers()
            self.wfile.write(body)

        def do_GET(self) -> None:  # noqa: N802
            if not self.local_request():
                self.json_response(403, {"ok": False, "error": "Local CARBON viewer requests only."})
                return
            route = urlparse(self.path).path
            if route == "/health":
                self.json_response(200, {"ok": True, "schema": "carbon.command-progress-viewer/v1", "instanceId": instance_id, "pid": os.getpid()})
                return
            if route == "/api/command-progress":
                if self.headers.get("X-Carbon-Command-Token") != token:
                    self.json_response(403, {"ok": False, "error": "Invalid CARBON command-progress session."})
                    return
                try:
                    self.json_response(200, {"ok": True, "progress": enrich(read_state(file))})
                except (OSError, ValueError, json.JSONDecodeError) as error:
                    self.json_response(400, {"ok": False, "error": str(error)})
                return
            if route.startswith("/api/command-evidence/"):
                if self.headers.get("X-Carbon-Command-Token") != token:
                    self.json_response(403, {"ok": False, "error": "Invalid CARBON command-progress session."})
                    return
                evidence_id = route.rsplit("/", 1)[-1]
                try:
                    state = read_state(file)
                    item = next((entry for entry in state.get("evidence", []) if entry.get("id") == evidence_id), None)
                    if not item or item.get("safeToShow") is not True or not item.get("imagePath"):
                        self.json_response(404, {"ok": False, "error": "Evidence preview unavailable."})
                        return
                    lexical_candidate = root / str(item["imagePath"])
                    if lexical_candidate.is_symlink():
                        self.json_response(404, {"ok": False, "error": "Evidence preview unavailable."})
                        return
                    candidate = lexical_candidate.resolve()
                    candidate.relative_to((root / ".carbon").resolve())
                    mime = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"}.get(candidate.suffix.lower())
                    if candidate.is_symlink() or not candidate.is_file() or not mime or candidate.stat().st_size > 12 * 1024 * 1024:
                        self.json_response(404, {"ok": False, "error": "Evidence preview unavailable."})
                        return
                    body = candidate.read_bytes()
                    self.send_response(200)
                    self.send_header("Content-Type", mime)
                    self.send_header("Content-Length", str(len(body)))
                    self.send_header("Cache-Control", "no-store")
                    self.send_header("X-Content-Type-Options", "nosniff")
                    self.end_headers()
                    self.wfile.write(body)
                except (OSError, ValueError, json.JSONDecodeError):
                    self.json_response(404, {"ok": False, "error": "Evidence preview unavailable."})
                return
            if route in {"/", "/command"}:
                self.send_response(200)
                self.send_header("Content-Type", "text/html; charset=utf-8")
                self.send_header("Content-Length", str(len(ui)))
                self.send_header("Cache-Control", "no-store")
                self.send_header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data: blob:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'")
                self.send_header("Referrer-Policy", "no-referrer")
                self.send_header("X-Content-Type-Options", "nosniff")
                self.send_header("X-Frame-Options", "DENY")
                self.end_headers()
                self.wfile.write(ui)
                return
            if route == "/favicon.ico":
                self.send_response(204)
                self.end_headers()
                return
            self.json_response(404, {"ok": False, "error": "Not found."})

    server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
    server.timeout = 1
    port = int(server.server_address[1])
    expires = dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=ttl_seconds)
    ready = {
        "schema": "carbon.command-progress-viewer/v1",
        "instanceId": instance_id,
        "pid": os.getpid(),
        "baseUrl": f"http://127.0.0.1:{port}",
        "progressId": progress_id,
        "statePath": str(file),
        "expiresAt": expires.replace(microsecond=0).isoformat().replace("+00:00", "Z"),
    }
    write_state(ready_path, {**ready, "schema": "carbon.command-progress-viewer/v1"})

    def request_stop(_signum: int, _frame: Any) -> None:
        nonlocal stop
        stop = True

    signal.signal(signal.SIGTERM, request_stop)
    signal.signal(signal.SIGINT, request_stop)
    try:
        while not stop and time.monotonic() - started < ttl_seconds:
            server.handle_request()
    finally:
        server.server_close()
        try:
            ready_path.unlink()
        except OSError:
            pass
    return 0


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("action", choices=("start", "update", "snapshot", "complete-latest", "serve"))
    parser.add_argument("--root", required=True)
    parser.add_argument("--progress-id")
    parser.add_argument("--command")
    parser.add_argument("--payload-json")
    parser.add_argument("--ready-file")
    parser.add_argument("--ttl-seconds", type=int, default=129_600)
    args = parser.parse_args()
    try:
        root = safe_root(args.root)
        payload = parse_payload(args.payload_json)
        if args.action == "start":
            result = start(root, payload)
        elif args.action == "update":
            if not args.progress_id:
                raise ValueError("update requires --progress-id")
            result = update(root, args.progress_id, payload)
        elif args.action == "snapshot":
            if not args.progress_id:
                raise ValueError("snapshot requires --progress-id")
            result = {"progressId": args.progress_id, "progress": enrich(read_state(state_path(root, args.progress_id)))}
        elif args.action == "complete-latest":
            if not args.command:
                raise ValueError("complete-latest requires --command")
            result = complete_latest(root, args.command, payload)
        else:
            if not args.progress_id or not args.ready_file:
                raise ValueError("serve requires --progress-id and --ready-file")
            if not 60 <= args.ttl_seconds <= 604_800:
                raise ValueError("ttlSeconds must be between 60 seconds and seven days")
            return serve(root, args.progress_id, args.ready_file, args.ttl_seconds)
        print(json.dumps(result, indent=2, ensure_ascii=False))
        return 0
    except (OSError, ValueError, json.JSONDecodeError) as error:
        print(f"carbon_command_progress: {error}", file=os.sys.stderr)
        return 2


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