"""Patch watcher detector. Entry point invoked by launchd every 15 min.

Reads last-seen state, hits Wago build feed, and for each new build on a
flavor we analyze, runs the analyzer and posts a notification when there
are at-risk call sites. PTR builds (wowt) are recorded but not analyzed.
On every poll, also drains any due scheduled releases (Phase 3).
"""
import datetime as _dt
import json
import random
import subprocess
import sys
from pathlib import Path

from bot.lib import state
from bot.lib.fixer import propose_fix
from bot.lib.http import HTTPError
from bot.lib.notify import notify
from bot.lib.release import ReleaseResult, run_release
from bot.lib.verify import verify as run_verify
from bot.lib.wago import Build, fetch_builds, latest_per_branch
from bot.lib.scheduler import next_peak_timestamp
from bot.lib.version_bump import apply_bump, decide_bump_kind, next_version, read_current_version
from bot.analyze import run as run_analyzer
from bot.approve import cmd_merge

ANALYZE_BRANCHES = {"wow", "wow_classic_era", "wow_classic"}
TRACK_ONLY_BRANCHES = {"wowt"}
FAILURE_NOTIFICATION_THRESHOLD = 3
AUTOFIX_MAX_SITES = 8
CONFIDENCE_THRESHOLD = 75


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


def _version_str_from_full(full: str) -> str:
    # "11.2.7.110207" -> "11.2.7"
    parts = full.split(".")
    return ".".join(parts[:-1]) if len(parts) > 1 else full


def _state_path(state_dir: Path) -> Path:
    return state_dir / "last-seen-builds.json"


def _append_notification_log(state_dir: Path, line: str) -> None:
    with (state_dir / "notifications.log").open("a", encoding="utf-8") as f:
        f.write(line.rstrip("\n") + "\n")


RELEASE_RETRY_CAP = 3


def _count_diff_lines(repo_root: Path) -> int:
    """Lines added + deleted between the last commit and its parent."""
    proc = subprocess.run(
        ["git", "-C", str(repo_root), "diff", "--shortstat", "HEAD~1..HEAD"],
        capture_output=True, text=True, check=False,
    )
    text = proc.stdout
    ins = del_ = 0
    import re as _re
    m_i = _re.search(r"(\d+) insertions?\(\+\)", text)
    m_d = _re.search(r"(\d+) deletions?\(-\)", text)
    if m_i:
        ins = int(m_i.group(1))
    if m_d:
        del_ = int(m_d.group(1))
    return ins + del_


def _drain_due_releases(state_dir: Path, repo_root: Path) -> None:
    if (state_dir / "auto-release-disabled").exists():
        return
    pending = state.load_pending_releases(state_dir)
    now = _dt.datetime.now(_dt.timezone.utc)
    for version, entry in list(pending.items()):
        try:
            scheduled = _dt.datetime.fromisoformat(entry["scheduled_at"])
        except (KeyError, ValueError):
            continue
        if scheduled.tzinfo is None:
            scheduled = scheduled.replace(tzinfo=_dt.timezone.utc)
        if scheduled > now:
            continue
        if int(entry.get("retries", 0)) >= RELEASE_RETRY_CAP:
            continue  # retry cap reached; entry preserved for manual intervention
        push = subprocess.run(
            ["git", "-C", str(repo_root), "push", "origin", "master", entry["tag"]],
            capture_output=True, text=True, check=False,
        )
        if push.returncode != 0:
            _bump_release_retry(state_dir, version, entry, push.stderr.strip() or push.stdout.strip())
            continue
        try:
            rr = run_release(repo_root)
        except FileNotFoundError as exc:
            notify(
                title="CritMatic patch watcher",
                subtitle=f"release {version} blocked",
                body=f"missing file: {exc}",
            )
            _append_notification_log(
                state_dir,
                f"{_now_iso()}  status=release-blocked  version={version}  error={exc}",
            )
            continue
        except KeyError as exc:
            notify(
                title="CritMatic patch watcher",
                subtitle=f"release {version} blocked",
                body=f"missing env var: {exc}",
            )
            _append_notification_log(
                state_dir,
                f"{_now_iso()}  status=release-blocked  version={version}  error=missing_env:{exc}",
            )
            continue
        if rr.success:
            state.remove_pending_release(state_dir, version)
            notify(
                title="CritMatic patch watcher",
                subtitle=f"released {version}",
                body=f"{entry.get('tag', version)} pushed and uploaded",
            )
            _append_notification_log(
                state_dir,
                f"{_now_iso()}  status=released  version={version}  tag={entry.get('tag', version)}",
            )
        else:
            _bump_release_retry(state_dir, version, entry, rr.stderr.strip() or rr.stdout.strip())


def _bump_release_retry(state_dir: Path, version: str, entry: dict, error: str) -> None:
    entry = {**entry, "retries": int(entry.get("retries", 0)) + 1, "last_error": error}
    state.record_pending_release(state_dir, version, entry)
    if entry["retries"] >= RELEASE_RETRY_CAP:
        notify(
            title="CritMatic patch watcher",
            subtitle=f"release {version} blocked",
            body=f"{entry['retries']} attempts failed; manual push needed",
        )
    _append_notification_log(
        state_dir,
        f"{_now_iso()}  status=release-failed  version={version}  attempt={entry['retries']}  error={error}",
    )


def _make_pending_fix_entry(report_path, status, fix, vr, detected_at, removed_symbols):
    return {
        "report_path": str(report_path),
        "status": status,
        "confidence": fix.confidence,
        "worktree_path": str(fix.worktree_path),
        "created_at": detected_at,
        "verify_rows": [
            {"name": r.name, "passed": r.passed, "detail": r.detail}
            for r in vr.rows
        ],
        "rationale": fix.rationale,
        "removed_symbols": sorted(removed_symbols),
    }


def _auto_release_pipeline(
    *, state_dir, repo_root, fix, vr, branch, new_version, removed_symbols,
    detected_at, report_path,
):
    # 1. Record the pending fix so cmd_merge has it.
    state.record_pending_fix(state_dir, fix.branch,
                             _make_pending_fix_entry(report_path, "auto-ship-eligible",
                                                     fix, vr, detected_at, removed_symbols))
    # 2. Auto-merge (re-runs verify last-mile, ff-only).
    rc, msg = cmd_merge(state_dir, repo_root, fix.branch)
    if rc != 0:
        notify(
            title="CritMatic patch watcher",
            subtitle=f"{branch} {new_version}",
            body=f"auto-merge blocked: {msg}",
            open_path=str(report_path),
        )
        _append_notification_log(
            state_dir,
            f"{detected_at}  {branch} {new_version}  status=auto-merge-blocked  fix-branch={fix.branch}  report={report_path}",
        )
        return
    # 3. Version bump.
    files_changed = len(fix.files_changed)
    diff_lines = _count_diff_lines(repo_root)
    kind = decide_bump_kind(files_changed, diff_lines)
    current = read_current_version(repo_root)
    new_v = next_version(current, kind)
    body = f"Compatibility fix for WoW {new_version}. {fix.rationale}. Files: {', '.join(fix.files_changed) or '(none)'}."
    today = _dt.datetime.now(_dt.timezone.utc).strftime("%m/%d/%Y")
    try:
        tag = apply_bump(
            repo_root=repo_root, new_version=new_v,
            changelog_body=body, today_mmddyyyy=today,
        )
    except Exception as exc:
        subprocess.run(["git", "-C", str(repo_root), "reset", "--hard", "HEAD~1"],
                       capture_output=True, text=True, check=False)
        notify(
            title="CritMatic patch watcher",
            subtitle=f"{branch} {new_version}",
            body=f"version bump failed: {exc}; auto-merge rolled back",
            open_path=str(report_path),
        )
        _append_notification_log(
            state_dir,
            f"{detected_at}  {branch} {new_version}  status=version-bump-failed  error={exc}",
        )
        return
    # 4. Schedule.
    rng = random.Random()
    scheduled_at = next_peak_timestamp(_dt.datetime.now(_dt.timezone.utc), rng)
    state.record_pending_release(state_dir, new_v, {
        "branch": branch, "tag": tag,
        "scheduled_at": scheduled_at.isoformat(),
        "status": "scheduled",
        "fix_branch": fix.branch,
        "report_path": str(report_path),
        "created_at": detected_at,
        "retries": 0,
    })
    notify(
        title="CritMatic patch watcher",
        subtitle=f"{branch} {new_version}",
        body=f"scheduled {tag} for {scheduled_at.isoformat()}",
        open_path=str(report_path),
    )
    _append_notification_log(
        state_dir,
        f"{detected_at}  {branch} {new_version}  status=scheduled  tag={tag}  at={scheduled_at.isoformat()}",
    )


def run(*, state_dir: Path, reports_dir: Path, repo_root: Path) -> int:
    if state.is_disabled(state_dir):
        return 0

    _drain_due_releases(state_dir, repo_root)

    try:
        builds: list[Build] = fetch_builds()
    except HTTPError as exc:
        n = state.bump_failure(state_dir)
        print(f"fetch_builds failed (count={n}): {exc}", file=sys.stderr)
        if n == FAILURE_NOTIFICATION_THRESHOLD:
            notify(
                title="CritMatic patch watcher",
                subtitle="offline",
                body=f"{n} consecutive failures, see launchd.log",
            )
            _append_notification_log(
                state_dir, f"{_now_iso()}  offline  failures={n}"
            )
        return 0
    state.reset_failures(state_dir)

    latest = latest_per_branch(builds)
    last_seen = state.load_last_seen(_state_path(state_dir))
    detected_at = _now_iso()

    new_state = dict(last_seen)
    for branch, b in latest.items():
        if branch not in ANALYZE_BRANCHES and branch not in TRACK_ONLY_BRANCHES:
            continue
        prev = last_seen.get(branch)
        if prev is None:
            # First sighting: record only, do not analyze.
            new_state[branch] = {"build": b.build, "version": _version_str_from_full(b.version), "seen_at": detected_at}
            continue
        if b.build <= int(prev["build"]):
            continue

        # We have a newer build for this branch.
        new_version = _version_str_from_full(b.version)
        prev_version = prev["version"]
        prev_build = int(prev["build"])

        if branch in ANALYZE_BRANCHES:
            try:
                count, report_path, removed_symbols = run_analyzer(
                    branch=branch,
                    new_version=new_version,
                    new_build=b.build,
                    previous_version=prev_version,
                    previous_build=prev_build,
                    repo_root=repo_root,
                    reports_dir=reports_dir,
                    detected_at=detected_at,
                )
            except Exception as exc:
                print(f"analyzer failed for {branch} {new_version}: {exc}", file=sys.stderr)
                continue
            if count > AUTOFIX_MAX_SITES:
                notify(
                    title="CritMatic patch watcher",
                    subtitle=f"{branch} {new_version}",
                    body=f"manual-review-needed: {count} at-risk sites. Report: {report_path}",
                    open_path=str(report_path),
                )
                _append_notification_log(
                    state_dir,
                    f"{detected_at}  {branch} {new_version}  status=manual-review-needed  sites={count}  report={report_path}",
                )
            elif count >= 1:
                fix_branch_name = f"bot/patch-{branch}-{new_version}".replace(".", "_")
                try:
                    fix = propose_fix(
                        report_path=report_path,
                        branch_name=fix_branch_name,
                        removed_symbols=removed_symbols,
                        repo_root=repo_root,
                    )
                    vr = run_verify(
                        worktree_path=fix.worktree_path,
                        removed_symbols=removed_symbols,
                        repo_root=repo_root,
                    )
                    if vr.passed_all and fix.confidence >= CONFIDENCE_THRESHOLD:
                        if (state_dir / "auto-release-disabled").exists():
                            # Phase 2 fallback: record pending-fix, manual approval.
                            state.record_pending_fix(state_dir, fix.branch,
                                _make_pending_fix_entry(report_path, "auto-ship-eligible",
                                                        fix, vr, detected_at, removed_symbols))
                            notify(
                                title="CritMatic patch watcher",
                                subtitle=f"{branch} {new_version}",
                                body=f"auto-ship-eligible (auto-release paused): {fix.branch}",
                                open_path=str(report_path),
                            )
                            _append_notification_log(
                                state_dir,
                                f"{detected_at}  {branch} {new_version}  status=auto-ship-eligible-paused  fix-branch={fix.branch}",
                            )
                        else:
                            _auto_release_pipeline(
                                state_dir=state_dir, repo_root=repo_root,
                                fix=fix, vr=vr, branch=branch, new_version=new_version,
                                removed_symbols=removed_symbols, detected_at=detected_at,
                                report_path=report_path,
                            )
                    else:
                        # held-for-review: record pending-fix, notify with failed checks.
                        state.record_pending_fix(state_dir, fix.branch,
                            _make_pending_fix_entry(report_path, "held-for-review",
                                                    fix, vr, detected_at, removed_symbols))
                        failed_names = [r.name for r in vr.rows if not r.passed]
                        body = (
                            f"held-for-review: {fix.branch}  confidence={fix.confidence}"
                            + (f"  failed=[{'; '.join(failed_names)}]" if failed_names else "")
                        )
                        notify(
                            title="CritMatic patch watcher",
                            subtitle=f"{branch} {new_version}",
                            body=body,
                            open_path=str(report_path),
                        )
                        log_line = (
                            f"{detected_at}  {branch} {new_version}  status=held-for-review"
                            f"  fix-branch={fix.branch}  confidence={fix.confidence}"
                            f"  report={report_path}"
                        )
                        if failed_names:
                            log_line += f"  failed={'; '.join(failed_names)}"
                        _append_notification_log(state_dir, log_line)
                except Exception as exc:
                    print(f"fixer failed for {branch} {new_version}: {exc}", file=sys.stderr)
                    notify(
                        title="CritMatic patch watcher",
                        subtitle=f"{branch} {new_version}",
                        body=f"fixer failed; manual review needed for {report_path}",
                        open_path=str(report_path),
                    )
                    _append_notification_log(
                        state_dir,
                        f"{detected_at}  {branch} {new_version}  status=fixer-failed  report={report_path}  error={exc}",
                    )
        # PTR (track-only) is recorded silently below.
        new_state[branch] = {"build": b.build, "version": new_version, "seen_at": detected_at}

    state.save_last_seen(_state_path(state_dir), new_state)
    return 0


def _main() -> int:
    repo_root = Path(__file__).resolve().parent.parent
    state_dir = repo_root / "bot" / "state"
    reports_dir = repo_root / "bot" / "reports"
    state_dir.mkdir(parents=True, exist_ok=True)
    reports_dir.mkdir(parents=True, exist_ok=True)
    return run(state_dir=state_dir, reports_dir=reports_dir, repo_root=repo_root)


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