# recoil/pipeline/lib/ops_log.py
"""Two-line ops.log.jsonl protocol (Phase 1).

Op lifecycle is two records: pending → (completed | failed | crashed). Crash
recovery scans the tail for dangling pending records.

Op id format (locked, spec D11): "op_" + uuid.uuid7().hex[:12]. Requires
Python 3.14+ for stdlib uuid.uuid7().
"""

from __future__ import annotations

import json
import sys
import threading
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from recoil.pipeline._lib.jsonl_append import append_jsonl_locked

_LOCK = threading.RLock()


def make_op_id() -> str:
    """Generate a 15-char op id: 'op_' + uuid7 hex prefix."""
    if sys.version_info < (3, 14):
        raise RuntimeError("uuid.uuid7() requires Python 3.14+. See spec D11.")
    return "op_" + uuid.uuid7().hex[:12]


def make_review_queue_id() -> str:
    if sys.version_info < (3, 14):
        raise RuntimeError("uuid.uuid7() requires Python 3.14+. See spec D11.")
    return "rq_" + uuid.uuid7().hex[:12]


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


def _append_jsonl(log_path: Path, record: dict[str, Any]) -> None:
    """Append one JSONL line. threading.RLock outer; fcntl.flock + fsync inner."""
    line = json.dumps(record, separators=(",", ":")) + "\n"
    with _LOCK:
        append_jsonl_locked(log_path, line)


def _default_event_log_path() -> Path:
    """Default destination for ad-hoc structured events (ops_log.write).

    Lives next to the dispatch receipts log so all engine-level audit
    streams are co-located. Override via RECOIL_OPS_LOG_PATH env var.
    """
    import os
    env_override = os.environ.get("RECOIL_OPS_LOG_PATH")
    if env_override:
        return Path(env_override)
    from recoil.core.paths import RECOIL_ROOT
    return RECOIL_ROOT / "_dispatch_logs" / "ops.log.jsonl"


def write(record: dict[str, Any], log_path: Path | None = None) -> None:
    """Append a free-form structured event to the ops log.

    For lifecycle events (e.g. orchestrator take/beat/sequence transitions)
    that don't match the op_id-keyed two-line started/completed protocol.
    Auto-stamps an ISO-8601 UTC `ts` if absent.
    """
    target = log_path if log_path is not None else _default_event_log_path()
    target.parent.mkdir(parents=True, exist_ok=True)
    enriched = dict(record)
    enriched.setdefault("ts", _now_iso())
    _append_jsonl(target, enriched)


def log_op_started(
    log_path: Path,
    *,
    op_id: str,
    name: str,
    args: dict,
    context: dict,
    parent_op_id: str | None = None,
) -> None:
    record = {
        "id": op_id,
        "ts": _now_iso(),
        "status": "pending",
        "name": name,
        "operator": context.get("operator", "unknown"),
        "args": args,
    }
    if parent_op_id is not None:
        record["parent_op_id"] = parent_op_id
    _append_jsonl(log_path, record)


def log_op_completed(
    log_path: Path,
    *,
    op_id: str,
    outputs: dict,
    cost: float | None = None,
) -> None:
    record = {
        "id": op_id,
        "ts": _now_iso(),
        "status": "completed",
        "outputs": outputs,
    }
    if cost is not None:
        record["cost"] = cost
    _append_jsonl(log_path, record)


def log_op_failed(
    log_path: Path,
    *,
    op_id: str,
    error: str,
    issues: list[str] | None = None,
) -> None:
    record = {
        "id": op_id,
        "ts": _now_iso(),
        "status": "failed",
        "error": error,
        "issues": issues or [],
    }
    _append_jsonl(log_path, record)


def log_op_crashed(
    log_path: Path,
    *,
    op_id: str,
    error: str,
    traceback_text: str,
) -> None:
    record = {
        "id": op_id,
        "ts": _now_iso(),
        "status": "crashed",
        "error": error,
        "traceback": traceback_text,
    }
    _append_jsonl(log_path, record)


def scan_for_dangling_ops(log_path: Path) -> list[dict]:
    """Return all pending records that have no completion/failure/crash record with the same id."""
    if not log_path.exists():
        return []
    pending: dict[str, dict] = {}
    closed: set[str] = set()
    with log_path.open() as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                record = json.loads(line)
                if record["status"] == "pending":
                    pending[record["id"]] = record
                elif record["status"] in ("completed", "failed", "crashed"):
                    closed.add(record["id"])
            except (json.JSONDecodeError, KeyError, TypeError):
                continue  # skip partial/corrupt/non-dict record
    return [r for op_id, r in pending.items() if op_id not in closed]
