#!/usr/bin/env python3
"""Launchd-fired Studio autonomy tick: decide, claim, and hand off."""

from __future__ import annotations

import argparse
import os
import re
import shlex
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Iterable, Mapping, Sequence

from recoil.pipeline.tools.autonomy import claim_ledger, constants, events
from recoil.pipeline.tools.autonomy import lease, linear_client, nightwatch_payload
from recoil.pipeline.tools.autonomy import readiness, reaper, resource_gate
from recoil.pipeline.tools.autonomy.preflight import human_active, preflight


REPO_ROOT = Path(__file__).resolve().parents[3]
SESSION_WORKSPACE = REPO_ROOT / "recoil/pipeline/tools/session_workspace.sh"
SUPERVISOR = Path(__file__).resolve().with_name("supervisor.sh")
BUILD_LOG_NAME = "build-log.md"
STARTUP_CONFIRM_SECONDS = 180.0
STARTUP_POLL_SECONDS = 5.0
DISPATCH_RUNS_ROOT = Path.home() / ".recoil/dispatch-runs"
DISPATCH_FRESH_SECONDS = constants.BUILD_WALLCLOCK_SECONDS + 600
STARTUP_MARKER_RE = re.compile(
    r"(^## Phase\b|Codex Spec Review|BUILD BLOCKED)",
    re.MULTILINE,
)
WORKTREE_RE = re.compile(r".*worktree: ([^ ]+).*")

Runner = Callable[..., subprocess.CompletedProcess[str]]
Sleeper = Callable[[float], None]
Clock = Callable[[], float]


def _utc_now() -> datetime:
    return datetime.now(timezone.utc)


def _run_id(now: datetime) -> str:
    return "auton-" + now.strftime("%Y%m%d-%H%M%S")


def _emit(event_type: str, run_id: str, night_id: str, **fields: Any) -> None:
    events.emit(event_type, run_id=run_id, night_id=night_id, **fields)


def _parse_iso(value: object) -> datetime | None:
    if not isinstance(value, str) or not value:
        return None
    try:
        parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
    except ValueError:
        return None
    if parsed.tzinfo is None:
        return parsed.replace(tzinfo=timezone.utc)
    return parsed.astimezone(timezone.utc)


def _fresh_build_lease(record: Mapping[str, Any] | None) -> bool:
    if not record or record.get("mode") != "build":
        return False
    expires_at = _parse_iso(record.get("expires_at"))
    return expires_at is not None and expires_at > _utc_now()


def _dispatch_status_active(
    *,
    runs_root: Path = DISPATCH_RUNS_ROOT,
    now: datetime | None = None,
) -> bool:
    terminal = {"CONVERGED_PR_CREATED", "CAPPED_NEEDS_HUMAN"}
    reference = now or _utc_now()
    for status_path in sorted(Path(runs_root).expanduser().glob("*/status.json")):
        try:
            import json

            status = json.loads(status_path.read_text(encoding="utf-8"))
        except Exception:
            continue
        if not isinstance(status, dict) or status.get("state") in terminal:
            continue
        updated_at = _parse_iso(status.get("updated_at") or status.get("created_at"))
        if updated_at is None:
            return True
        age = (reference - updated_at).total_seconds()
        if age <= DISPATCH_FRESH_SECONDS:
            return True
    return False


def _has_startup_marker(build_log: Path) -> bool:
    try:
        text = build_log.read_text(encoding="utf-8", errors="replace")
    except FileNotFoundError:
        return False
    except OSError:
        return False
    return bool(STARTUP_MARKER_RE.search(text))


def _live_tmux_with_build_marker(*, runner: Runner = subprocess.run) -> bool:
    try:
        result = runner(
            ["tmux", "ls"],
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            check=False,
        )
    except Exception:
        return False
    if result.returncode != 0:
        return False

    for line in result.stdout.splitlines():
        session = line.split(":", 1)[0].strip()
        if not session.startswith("autonomy-"):
            continue
        try:
            cwd_result = runner(
                ["tmux", "display-message", "-p", "-t", session, "#{pane_current_path}"],
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
                check=False,
            )
        except Exception:
            continue
        if cwd_result.returncode != 0:
            continue
        cwd = cwd_result.stdout.strip()
        if cwd and _has_startup_marker(Path(cwd) / BUILD_LOG_NAME):
            return True
    return False


def _busy_reason(*, runner: Runner = subprocess.run) -> str | None:
    if _dispatch_status_active():
        return "dispatch_status_active"
    if _fresh_build_lease(lease.read()):
        return "build_lease_live"
    if _live_tmux_with_build_marker(runner=runner):
        return "tmux_build_log_live"
    return None


def _cap_event(reason: str) -> str:
    if reason in {"builds_cap", "night_breaker"} or resource_gate.is_rate_limit_error(reason):
        return "cap_tripped"
    return "busy_exit"


def _candidate_priority(candidate: Mapping[str, Any]) -> tuple[int, int, str]:
    raw = candidate.get("priority")
    try:
        priority = int(raw)
    except (TypeError, ValueError):
        priority = 999
    if priority <= 0:
        priority = 999

    sort_order_raw = candidate.get("prioritySortOrder")
    try:
        sort_order = int(sort_order_raw)
    except (TypeError, ValueError):
        sort_order = priority

    return (priority, sort_order, str(candidate.get("identifier") or ""))


def _ready_candidates(candidates: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
    ready: list[dict[str, Any]] = []
    for candidate in candidates:
        is_ready, _reasons = readiness.is_ready(candidate)
        if is_ready:
            ready.append(candidate)
    return sorted(ready, key=_candidate_priority)


def _issue_id(candidate: Mapping[str, Any]) -> str:
    return str(candidate.get("issue_id") or candidate.get("id") or "")


def _identifier(candidate: Mapping[str, Any]) -> str:
    return str(candidate.get("identifier") or "")


def _parse_worktree(output: str) -> Path | None:
    for line in output.splitlines():
        match = WORKTREE_RE.match(line)
        if match:
            return Path(match.group(1)).expanduser()
    return None


def _under_worktrees_root(path: Path) -> bool:
    resolved = path.expanduser().resolve(strict=False)
    root = constants.WORKTREES_ROOT.expanduser().resolve(strict=False)
    try:
        resolved.relative_to(root)
    except ValueError:
        return False
    return True


def _create_worktree(
    identifier: str,
    run_id: str,
    *,
    runner: Runner = subprocess.run,
) -> Path:
    result = runner(
        [
            str(SESSION_WORKSPACE),
            "create",
            "--actor",
            "codex",
            "--issue",
            identifier,
            "--slug",
            run_id,
        ],
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        check=False,
    )
    output = "\n".join([result.stdout or "", result.stderr or ""])
    worktree = _parse_worktree(output)
    if result.returncode != 0 or worktree is None or not _under_worktrees_root(worktree):
        raise RuntimeError(output.strip() or "session_workspace create failed")
    return worktree.expanduser().resolve(strict=False)


def _supervisor_command(
    run_id: str,
    identifier: str,
    worktree: Path,
    *,
    shadow: str | None,
) -> str:
    args = [
        "bash",
        str(SUPERVISOR),
        "--run-id",
        run_id,
        "--issue",
        identifier,
        "--worktree",
        str(worktree),
    ]
    if shadow:
        args.extend(["--shadow", shadow])
    return shlex.join(args)


def _launch_supervisor(
    run_id: str,
    identifier: str,
    worktree: Path,
    *,
    shadow: str | None,
    runner: Runner = subprocess.run,
) -> str:
    tmux_session = f"autonomy-{run_id}"
    result = runner(
        [
            "tmux",
            "new-session",
            "-d",
            "-s",
            tmux_session,
            "-c",
            str(worktree),
            _supervisor_command(run_id, identifier, worktree, shadow=shadow),
        ],
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        check=False,
    )
    if result.returncode != 0:
        raise RuntimeError((result.stderr or result.stdout or "tmux launch failed").strip())
    return tmux_session


def _kill_tmux(tmux_session: str, *, runner: Runner = subprocess.run) -> None:
    try:
        runner(
            ["tmux", "kill-session", "-t", tmux_session],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            check=False,
        )
    except Exception:
        return


def _wait_for_startup_marker(
    worktree: Path,
    *,
    timeout: float = STARTUP_CONFIRM_SECONDS,
    poll_interval: float = STARTUP_POLL_SECONDS,
    sleeper: Sleeper = time.sleep,
    monotonic: Clock = time.monotonic,
) -> bool:
    build_log = worktree / BUILD_LOG_NAME
    deadline = monotonic() + max(0.0, timeout)
    while True:
        if _has_startup_marker(build_log):
            return True
        if monotonic() >= deadline:
            return False
        sleeper(min(max(0.0, poll_interval), max(0.0, deadline - monotonic())))


def _release_after_failed_launch(
    issue_id: str,
    run_id: str,
    reason: str,
    *,
    tmux_session: str | None = None,
    runner: Runner = subprocess.run,
) -> None:
    if tmux_session:
        _kill_tmux(tmux_session, runner=runner)
    claim_ledger.release(issue_id, run_id, "failed", failure_signature=reason)
    lease.release(run_id)


def tick_once(
    *,
    shadow: str | None = None,
    runner: Runner = subprocess.run,
    sleeper: Sleeper = time.sleep,
    monotonic: Clock = time.monotonic,
    environ: Mapping[str, str] | None = None,
    now: datetime | None = None,
    startup_timeout: float = STARTUP_CONFIRM_SECONDS,
    startup_poll_interval: float = STARTUP_POLL_SECONDS,
) -> int:
    now_dt = now or datetime.now()
    run_id = _run_id(now_dt)
    night = constants.current_night_id(now_dt)
    _emit("tick_started", run_id, night)

    ok, reason = preflight()
    if not ok:
        _emit("preflight_failed", run_id, night, reason=reason)
        return 0

    reaper.reap()

    lease_record = lease.acquire(
        "tick",
        run_id=run_id,
        ttl=constants.TICK_LEASE_TTL,
        pid=os.getpid(),
    )
    if lease_record is None:
        _emit("busy_exit", run_id, night, reason="lease_busy")
        return 0

    busy_reason = _busy_reason(runner=runner)
    if busy_reason:
        lease.release(run_id)
        _emit("busy_exit", run_id, night, reason=busy_reason)
        return 0

    active, why = human_active(REPO_ROOT)
    if active:
        lease.release(run_id)
        _emit("yield_human_active", run_id, night, reason=why)
        return 0

    can_start, why = resource_gate.may_start_build(night)
    if not can_start:
        lease.release(run_id)
        _emit(_cap_event(why), run_id, night, reason=why)
        return 0

    env = os.environ if environ is None else environ
    team = env["AUTONOMY_LINEAR_TEAM"]
    ready = _ready_candidates(linear_client.list_candidates(team))
    if not ready:
        nightwatch_payload.run_maintenance(run_id, night, shadow=shadow)
        lease.release(run_id)
        _emit("no_work", run_id, night)
        return 0

    candidate = ready[0]
    issue_id = _issue_id(candidate)
    identifier = _identifier(candidate)
    claim = claim_ledger.claim(issue_id, identifier, run_id, night)
    if claim is None:
        lease.release(run_id)
        _emit("busy_exit", run_id, night, reason="claim_race")
        return 0

    _emit("issue_claimed", run_id, night, issue_id=issue_id)

    if shadow == "tier0":
        claim_ledger.release(issue_id, run_id, "released")
        lease.release(run_id)
        _emit("shadow_would_dispatch", run_id, night, issue_id=issue_id, shadow=shadow)
        return 0

    try:
        linear_client.project_claim(issue_id, identifier, run_id)
    except Exception:
        pass

    try:
        worktree = _create_worktree(identifier, run_id, runner=runner)
    except Exception as exc:
        _release_after_failed_launch(issue_id, run_id, "worktree_create_failed")
        _emit("build_killed", run_id, night, issue_id=issue_id, reason="worktree_create_failed", detail=str(exc))
        return 1

    tmux_session: str | None = None
    try:
        tmux_session = _launch_supervisor(
            run_id,
            identifier,
            worktree,
            shadow=shadow,
            runner=runner,
        )
    except Exception as exc:
        _release_after_failed_launch(issue_id, run_id, "supervisor_launch_failed")
        _emit("build_killed", run_id, night, issue_id=issue_id, reason="supervisor_launch_failed", detail=str(exc))
        return 1

    if not _wait_for_startup_marker(
        worktree,
        timeout=startup_timeout,
        poll_interval=startup_poll_interval,
        sleeper=sleeper,
        monotonic=monotonic,
    ):
        _release_after_failed_launch(
            issue_id,
            run_id,
            "startup_marker_absent",
            tmux_session=tmux_session,
            runner=runner,
        )
        _emit("build_killed", run_id, night, issue_id=issue_id, reason="startup_marker_absent")
        return 1

    if not lease.convert(
        run_id,
        new_mode="build",
        new_ttl=constants.BUILD_LEASE_TTL,
        tmux_session=tmux_session,
    ):
        _release_after_failed_launch(
            issue_id,
            run_id,
            "lease_convert_failed",
            tmux_session=tmux_session,
            runner=runner,
        )
        _emit("build_killed", run_id, night, issue_id=issue_id, reason="lease_convert_failed")
        return 1

    resource_gate.record_build_started(night)
    _emit(
        "dispatch_started",
        run_id,
        night,
        issue_id=issue_id,
        tmux_session=tmux_session,
        worktree_path=str(worktree),
    )
    return 0


def _build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--shadow", choices=["tier0", "tier1"])
    return parser


def main(argv: Sequence[str] | None = None) -> int:
    args = _build_parser().parse_args(argv)
    return tick_once(shadow=args.shadow)


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