"""Local branches/ worktree hygiene scan for the web UI (#432)."""

from __future__ import annotations

import os
import re
import subprocess
from dataclasses import dataclass
from typing import Any, Callable

from issue_lock_provenance import ISSUE_LOCK_FILE
from merged_cleanup_reconcile import (
    branch_worktree_folder,
    read_issue_lock,
    read_local_worktree_state,
)
from reviewer_worktree import REVIEW_WORKTREE_RE

CLASSIFICATIONS = frozenset({
    "active-pr",
    "active-issue",
    "dirty",
    "stale-clean",
    "detached-review",
    "unsafe-unknown",
    "orphan",
})

CLEANUP_PROMPT = (
    "Task: clean up branch/worktree for PR #<pr> / issue #<n> after merge. "
    "Confirm the merge on remote master before any deletion; never "
    "force-remove a dirty worktree. Full steps: "
    "skills/llm-project-workflow/templates/worktree-cleanup.md"
)


@dataclass(frozen=True)
class WorktreeEntry:
    rel_path: str
    folder_name: str
    classification: str
    branch: str | None
    head_sha: str | None
    dirty_tracked: int
    dirty_untracked: bool
    detached: bool
    registered_worktree: bool
    notes: str


@dataclass(frozen=True)
class HygieneSnapshot:
    repo_root: str
    entries: tuple[WorktreeEntry, ...]
    anomalies: tuple[str, ...]
    cleanup_prompt: str
    scan_error: str | None = None


def _repo_root() -> str:
    override = (os.environ.get("WEBUI_REPO_ROOT") or "").strip()
    if override:
        return os.path.realpath(override)
    return os.path.realpath(os.path.join(os.path.dirname(__file__), ".."))


def _relative_branches_path(project_root: str, path: str) -> str:
    root = os.path.normpath(project_root)
    normalized = os.path.normpath(path)
    if normalized.startswith(root + os.sep):
        return normalized[len(root) + 1 :].replace("\\", "/")
    return normalized.replace("\\", "/")


def parse_worktree_list_porcelain(porcelain: str) -> list[dict[str, Any]]:
    entries: list[dict[str, Any]] = []
    current: dict[str, Any] = {}
    for raw in (porcelain or "").splitlines():
        line = raw.strip()
        if not line:
            if current:
                entries.append(current)
                current = {}
            continue
        if line.startswith("worktree "):
            if current:
                entries.append(current)
            current = {"path": line.split(" ", 1)[1].strip()}
        elif line.startswith("HEAD "):
            current["head_sha"] = line.split(" ", 1)[1].strip()
        elif line.startswith("branch "):
            current["branch"] = line.split(" ", 1)[1].strip().removeprefix("refs/heads/")
        elif line == "detached":
            current["detached"] = True
    if current:
        entries.append(current)
    return entries


def list_branches_directories(project_root: str) -> list[str]:
    branches_root = os.path.join(project_root, "branches")
    if not os.path.isdir(branches_root):
        return []
    names: list[str] = []
    for entry in sorted(os.listdir(branches_root)):
        if entry.startswith("."):
            continue
        full = os.path.join(branches_root, entry)
        if os.path.isdir(full):
            names.append(f"branches/{entry}")
    return names


def _untracked_dirty(porcelain: str) -> bool:
    return any(line.startswith("??") for line in (porcelain or "").splitlines())


def classify_entry(
    *,
    rel_path: str,
    worktree_record: dict[str, Any] | None,
    state: dict[str, Any],
    open_pr_branches: set[str],
    active_lock_branch: str | None,
) -> tuple[str, str]:
    record = worktree_record or {}
    branch_name = (record.get("branch") or state.get("current_branch") or "").strip()
    folder_name = rel_path.split("/", 1)[-1] if "/" in rel_path else rel_path

    if branch_name in open_pr_branches:
        return "active-pr", "Head branch matches an open PR"
    if active_lock_branch and branch_name == active_lock_branch:
        return "active-issue", "Matches active issue lock branch"

    dirty_tracked = len(state.get("dirty_files") or [])
    dirty_untracked = bool(state.get("dirty_untracked"))
    if dirty_tracked or dirty_untracked:
        return "dirty", "Local tracked or untracked changes present"

    if not record and not state.get("exists"):
        return "orphan", "Directory missing on disk"

    if not record:
        return "orphan", "Directory exists but is not a registered git worktree"

    if record.get("detached") and REVIEW_WORKTREE_RE.search(rel_path):
        return "detached-review", "Detached HEAD in reviewer/simulation worktree"

    if state.get("exists") and state.get("clean"):
        return "stale-clean", "Registered worktree with clean working tree"

    return "unsafe-unknown", "Could not classify safely — inspect manually before cleanup"


def _missing_preserved_anomalies(
    project_root: str,
    lock: dict[str, Any] | None,
    entries_by_path: dict[str, WorktreeEntry],
) -> tuple[str, ...]:
    anomalies: list[str] = []
    if not lock:
        return tuple(anomalies)

    branch = (lock.get("branch_name") or "").strip()
    issue_number = lock.get("issue_number")
    worktree_path = (lock.get("worktree_path") or "").strip()
    expected_rel = _relative_branches_path(project_root, worktree_path) if worktree_path else ""
    if worktree_path and not os.path.isdir(worktree_path):
        anomalies.append(
            f"Missing preserved worktree for issue #{issue_number}: "
            f"lock path {worktree_path!r} not found on disk (#404)"
        )
    elif expected_rel.startswith("branches/") and expected_rel not in entries_by_path:
        anomalies.append(
            f"Missing preserved worktree folder {expected_rel} for locked branch {branch!r}"
        )
    elif branch:
        folder = f"branches/{branch_worktree_folder(branch)}"
        entry = entries_by_path.get(folder)
        if entry and entry.classification in {"orphan", "unsafe-unknown"}:
            anomalies.append(
                f"Locked branch {branch!r} maps to {folder} but classification is "
                f"{entry.classification}"
            )
    return tuple(anomalies)


def load_hygiene_snapshot(
    *,
    project_root: str | None = None,
    worktree_porcelain: str | None = None,
    open_pr_branches: set[str] | None = None,
    issue_lock_path: str | None = None,
    run_git: Callable[[list[str]], subprocess.CompletedProcess[str]] | None = None,
) -> HygieneSnapshot:
    root = project_root or _repo_root()
    lock_path = issue_lock_path if issue_lock_path is not None else ISSUE_LOCK_FILE
    lock = read_issue_lock(lock_path)
    active_lock_branch = (lock.get("branch_name") or "").strip() if lock else None

    git_run = run_git or (
        lambda args: subprocess.run(args, capture_output=True, text=True, check=False)
    )

    scan_error: str | None = None
    porcelain = worktree_porcelain
    if porcelain is None:
        result = git_run(["git", "-C", root, "worktree", "list", "--porcelain"])
        if result.returncode != 0:
            scan_error = (result.stderr or result.stdout or "git worktree list failed").strip()
            porcelain = ""
        else:
            porcelain = result.stdout or ""

    worktrees = parse_worktree_list_porcelain(porcelain)
    worktree_by_rel: dict[str, dict[str, Any]] = {}
    for wt in worktrees:
        rel = _relative_branches_path(root, wt.get("path") or "")
        if rel.startswith("branches/"):
            worktree_by_rel[rel] = wt

    open_heads = set(open_pr_branches or ())
    if not open_heads:
        try:
            from webui.queue_loader import load_queue_snapshot

            snapshot = load_queue_snapshot()
            open_heads = {
                item.extra.get("branch", "")
                for item in snapshot.prs
                if item.extra.get("branch")
            }
        except Exception:
            open_heads = set()

    entries: list[WorktreeEntry] = []
    for rel_path in list_branches_directories(root):
        abs_path = os.path.join(root, rel_path)
        record = worktree_by_rel.get(rel_path)
        state = read_local_worktree_state(abs_path)
        if state.get("exists"):
            status = git_run(["git", "-C", abs_path, "status", "--porcelain"])
            state = {
                **state,
                "dirty_untracked": _untracked_dirty(status.stdout or ""),
            }
        classification, note = classify_entry(
            rel_path=rel_path,
            worktree_record=record,
            state=state,
            open_pr_branches=open_heads,
            active_lock_branch=active_lock_branch,
        )
        entries.append(
            WorktreeEntry(
                rel_path=rel_path,
                folder_name=rel_path.split("/", 1)[-1],
                classification=classification,
                branch=(record or {}).get("branch") or state.get("current_branch"),
                head_sha=(record or {}).get("head_sha") or state.get("head_sha"),
                dirty_tracked=len(state.get("dirty_files") or []),
                dirty_untracked=bool(state.get("dirty_untracked")),
                detached=bool((record or {}).get("detached")),
                registered_worktree=record is not None,
                notes=note,
            )
        )

    entries_by_path = {entry.rel_path: entry for entry in entries}
    anomalies = _missing_preserved_anomalies(root, lock, entries_by_path)

    return HygieneSnapshot(
        repo_root=root,
        entries=tuple(entries),
        anomalies=anomalies,
        cleanup_prompt=CLEANUP_PROMPT,
        scan_error=scan_error,
    )


def snapshot_to_dict(snapshot: HygieneSnapshot) -> dict[str, Any]:
    return {
        "repo_root": snapshot.repo_root,
        "count": len(snapshot.entries),
        "cleanup_prompt": snapshot.cleanup_prompt,
        "anomalies": list(snapshot.anomalies),
        "scan_error": snapshot.scan_error,
        "entries": [
            {
                "rel_path": entry.rel_path,
                "folder_name": entry.folder_name,
                "classification": entry.classification,
                "branch": entry.branch,
                "head_sha": entry.head_sha,
                "dirty_tracked": entry.dirty_tracked,
                "dirty_untracked": entry.dirty_untracked,
                "detached": entry.detached,
                "registered_worktree": entry.registered_worktree,
                "notes": entry.notes,
            }
            for entry in snapshot.entries
        ],
    }