"""Merged PR branch and worktree cleanup reconciliation (#269).

Builds dry-run reports for merged pull requests and optionally executes
remote branch deletion and local worktree removal when every safety gate passes.
"""

from __future__ import annotations

import json
import os
import re
import subprocess
from typing import Any

from reviewer_worktree import parse_dirty_tracked_files
import issue_lock_store

PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
CLOSES_FIXES_RE = re.compile(r"\b(?:closes|fixes)\s+#(\d+)\b", re.IGNORECASE)
ISSUE_MARKER_RE = re.compile(r"(?:^|[-_/])issue-(\d+)(?:[-_/]|$)", re.IGNORECASE)
# Reviewer scratch folders: review-pr<N> or review-pr<N>-submit (#534).
REVIEWER_SCRATCH_NAME_RE = re.compile(
    r"^review-pr(?P<pr>\d+)(?:-submit)?$",
    re.IGNORECASE,
)


def extract_linked_issue(title: str, body: str) -> int | None:
    """Return the first Closes/Fixes issue number from PR metadata."""
    for text in (title or "", body or ""):
        match = CLOSES_FIXES_RE.search(text)
        if match:
            return int(match.group(1))
    return None


def branch_worktree_folder(branch: str) -> str:
    return (branch or "").replace("/", "-")


def resolve_worktree_path(project_root: str, branch: str) -> str:
    return os.path.join(project_root, "branches", branch_worktree_folder(branch))


def extract_issue_marker(*values: str | None) -> int | None:
    """Return the first issue marker found in branch/path-like values."""
    for value in values:
        match = ISSUE_MARKER_RE.search(value or "")
        if match:
            return int(match.group(1))
    return None


def list_local_worktrees(project_root: str) -> list[dict[str, Any]]:
    """Parse ``git worktree list --porcelain`` into structured entries."""
    res = subprocess.run(
        ["git", "-C", project_root, "worktree", "list", "--porcelain"],
        capture_output=True,
        text=True,
        check=False,
    )
    if res.returncode != 0:
        return []

    entries: list[dict[str, Any]] = []
    current: dict[str, Any] | None = None

    def flush() -> None:
        nonlocal current
        if current:
            entries.append(current)
        current = None

    for raw_line in (res.stdout or "").splitlines():
        line = raw_line.strip()
        if not line:
            flush()
            continue
        if line.startswith("worktree "):
            flush()
            current = {"path": line[9:].strip()}
        elif current is not None and line.startswith("HEAD "):
            current["head_sha"] = line[5:].strip()
        elif current is not None and line.startswith("branch "):
            ref = line[7:].strip()
            current["branch_ref"] = ref
            current["branch"] = (
                ref[len("refs/heads/"):]
                if ref.startswith("refs/heads/")
                else ref
            )
        elif current is not None and line == "detached":
            current["detached"] = True
    flush()
    return entries


def _git_worktree_porcelain(project_root: str) -> list[dict[str, str | None]]:
    """Parse ``git worktree list --porcelain`` into structured records."""
    res = subprocess.run(
        ["git", "-C", project_root, "worktree", "list", "--porcelain"],
        capture_output=True,
        text=True,
        check=False,
    )
    if res.returncode != 0:
        return []

    records: list[dict[str, str | None]] = []
    current: dict[str, str | None] = {}
    for line in res.stdout.splitlines():
        line = line.strip()
        if not line:
            if current.get("worktree"):
                records.append(current)
            current = {}
            continue
        if line.startswith("worktree "):
            if current.get("worktree"):
                records.append(current)
            current = {"worktree": line[9:].strip(), "branch": None, "head": None}
        elif line.startswith("branch ") and current:
            ref = line[7:].strip()
            current["branch"] = (
                ref[11:] if ref.startswith("refs/heads/") else ref
            )
        elif line.startswith("HEAD ") and current:
            current["head"] = line[5:].strip()
        elif line == "detached" and current:
            current["branch"] = None
    if current.get("worktree"):
        records.append(current)
    return records


def discover_local_worktrees(project_root: str) -> dict[str, str]:
    """Return a mapping from branch name to absolute worktree path (#528)."""
    mapping: dict[str, str] = {}
    for entry in list_local_worktrees(project_root):
        branch = entry.get("branch")
        path = entry.get("path")
        if branch and path:
            mapping[str(branch)] = str(path)
    return mapping


def _is_under_branches(project_root: str, path: str) -> bool:
    branches_root = os.path.realpath(os.path.join(project_root, "branches"))
    real_path = os.path.realpath(path)
    return real_path == branches_root or real_path.startswith(branches_root + os.sep)


def _head_is_safe_for_cleanup(
    *,
    project_root: str,
    candidate_head: str | None,
    pr_head_sha: str | None,
    target_ref: str | None,
) -> bool:
    if not candidate_head:
        return False
    if pr_head_sha and candidate_head == pr_head_sha:
        return True
    if target_ref:
        return is_head_ancestor_of_ref(project_root, candidate_head, target_ref) is True
    return False


def resolve_cleanup_worktree_state(
    *,
    project_root: str,
    head_branch: str,
    issue_number: int | None,
    pr_head_sha: str | None = None,
    target_ref: str | None = None,
) -> dict[str, Any]:
    """Find the exact or safe alias worktree used for local cleanup (#532)."""
    expected_path = resolve_worktree_path(project_root, head_branch)
    state = read_local_worktree_state(expected_path)
    state["worktree_path"] = expected_path
    state["expected_worktree_path"] = expected_path
    state["discovered_worktree_path"] = expected_path if state.get("exists") else None
    state["match_type"] = "derived_path" if state.get("exists") else "none"
    state["candidate_paths"] = []
    state["alias_block_reasons"] = []
    if state.get("exists"):
        return state

    branch_issue = extract_issue_marker(head_branch)
    wanted_issue = issue_number or branch_issue
    candidates: list[dict[str, Any]] = []
    unsafe_matches: list[str] = []

    for entry in list_local_worktrees(project_root):
        path = entry.get("path") or ""
        if not path or not _is_under_branches(project_root, path):
            continue
        branch = entry.get("branch") or ""
        path_issue = extract_issue_marker(os.path.basename(path), path)
        entry_issue = extract_issue_marker(branch) or path_issue
        branch_match = branch == head_branch
        issue_match = wanted_issue is not None and entry_issue == wanted_issue
        if not (branch_match or issue_match):
            continue
        safe_head = _head_is_safe_for_cleanup(
            project_root=project_root,
            candidate_head=entry.get("head_sha"),
            pr_head_sha=pr_head_sha,
            target_ref=target_ref,
        )
        if not safe_head and branch_match and not pr_head_sha and not target_ref:
            safe_head = True
        if not safe_head:
            unsafe_matches.append(path)
            continue
        candidates.append(entry)

    state["candidate_paths"] = [entry.get("path") for entry in candidates]
    if len(candidates) == 1:
        path = candidates[0]["path"]
        alias_state = read_local_worktree_state(path)
        alias_state["worktree_path"] = path
        alias_state["expected_worktree_path"] = expected_path
        alias_state["discovered_worktree_path"] = path
        alias_state["match_type"] = (
            "branch" if candidates[0].get("branch") == head_branch else "alias"
        )
        alias_state["candidate_paths"] = [path]
        alias_state["alias_block_reasons"] = []
        alias_state["alias_verified"] = True
        return alias_state
    if len(candidates) > 1:
        state["alias_block_reasons"].append(
            "ambiguous local worktree aliases: " + ", ".join(
                sorted(str(entry.get("path")) for entry in candidates)
            )
        )
        state["match_type"] = "ambiguous_alias"
    elif unsafe_matches:
        state["alias_block_reasons"].append(
            "matching local worktree aliases did not point to the PR head or target branch: "
            + ", ".join(sorted(unsafe_matches))
        )
        state["match_type"] = "unsafe_alias"
    return state


def parse_reviewer_scratch_folder(folder_name: str) -> int | None:
    """Return PR number for a reviewer scratch folder name, else None (#534)."""
    match = REVIEWER_SCRATCH_NAME_RE.match((folder_name or "").strip())
    if not match:
        return None
    return int(match.group("pr"))


def discover_reviewer_scratch_worktrees(project_root: str) -> list[dict[str, Any]]:
    """Discover detached reviewer scratch worktrees under ``branches/`` (#534).

    Matches folder names ``review-pr<N>`` and ``review-pr<N>-submit`` only.
    These are never treated as author worktree paths.
    """
    root = os.path.realpath(project_root)
    branches_root = os.path.realpath(os.path.join(root, "branches"))
    found: list[dict[str, Any]] = []
    for record in _git_worktree_porcelain(project_root):
        path = record.get("worktree")
        if not path:
            continue
        real = os.path.realpath(path)
        parent = os.path.dirname(real)
        if parent != branches_root:
            continue
        folder = os.path.basename(real)
        pr_number = parse_reviewer_scratch_folder(folder)
        if pr_number is None:
            continue
        found.append(
            {
                "pr_number": pr_number,
                "worktree_path": real,
                "folder_name": folder,
                "current_branch": record.get("branch"),
                "head_sha": record.get("head"),
                "worktree_kind": "reviewer_scratch",
            }
        )
    return found


def assess_reviewer_scratch_cleanup(
    *,
    pr_number: int,
    worktree_path: str,
    folder_name: str,
    pr_merged: bool,
    pr_closed: bool,
    worktree_state: dict[str, Any],
    active_reviewer_lease: bool,
) -> dict[str, Any]:
    """Assess whether a reviewer scratch worktree is safe to remove (#534)."""
    reasons: list[str] = []
    exists = bool(worktree_state.get("exists"))
    if not (pr_merged or pr_closed):
        reasons.append("associated PR is still open")
    if not exists:
        reasons.append("reviewer scratch worktree not present")
    if exists and worktree_state.get("clean") is False:
        dirty = worktree_state.get("dirty_files") or []
        reasons.append(
            "reviewer scratch worktree has tracked edits"
            + (f" ({', '.join(dirty)})" if dirty else "")
        )
    if active_reviewer_lease:
        reasons.append("active reviewer lease still requires this worktree")
    current_branch = worktree_state.get("current_branch")
    if current_branch:
        # Scratch trees are expected detached; a named branch is ambiguous ownership.
        reasons.append(
            f"reviewer scratch worktree is on branch '{current_branch}' "
            "(expected detached HEAD for review-pr scratch trees)"
        )

    safe = exists and not reasons
    return {
        "pr_number": pr_number,
        "worktree_path": worktree_path,
        "folder_name": folder_name,
        "worktree_kind": "reviewer_scratch",
        "worktree_exists": exists,
        "worktree_clean": worktree_state.get("clean"),
        "safe_to_remove_worktree": safe,
        "block_reasons": reasons,
        "recommended_action": (
            "remove_reviewer_scratch_worktree" if safe else "keep_reviewer_scratch_worktree"
        ),
        # Never treat as author worktree cleanup.
        "author_worktree_cleanup": False,
    }


def read_issue_lock(path: str | None = None) -> dict[str, Any] | None:
    if path:
        return issue_lock_store.read_lock_file(path.strip())
    return issue_lock_store.read_session_issue_lock()


def has_active_issue_lock(branch: str, lock_path: str | None = None) -> bool:
    if lock_path:
        lock = issue_lock_store.read_lock_file(lock_path.strip())
        if not lock:
            return False
        return (lock.get("branch_name") or "").strip() == (branch or "").strip()
    return issue_lock_store.has_active_issue_lock(branch)


def collect_open_pr_heads(open_prs: list[dict[str, Any]]) -> set[str]:
    heads: set[str] = set()
    for pr in open_prs or []:
        head = pr.get("head")
        if isinstance(head, dict):
            ref = head.get("ref")
        else:
            ref = head
        if ref:
            heads.add(str(ref))
    return heads


def read_local_worktree_state(worktree_path: str) -> dict[str, Any]:
    path = (worktree_path or "").strip()
    if not path or not os.path.isdir(path):
        return {
            "exists": False,
            "clean": None,
            "current_branch": None,
            "head_sha": None,
            "dirty_files": [],
        }

    branch_res = subprocess.run(
        ["git", "-C", path, "branch", "--show-current"],
        capture_output=True,
        text=True,
        check=False,
    )
    current_branch = (branch_res.stdout or "").strip() or None

    status_res = subprocess.run(
        ["git", "-C", path, "status", "--porcelain"],
        capture_output=True,
        text=True,
        check=False,
    )
    dirty_files = parse_dirty_tracked_files(status_res.stdout or "")

    head_res = subprocess.run(
        ["git", "-C", path, "rev-parse", "HEAD"],
        capture_output=True,
        text=True,
        check=False,
    )
    head_sha = (head_res.stdout or "").strip() if head_res.returncode == 0 else None

    return {
        "exists": True,
        "clean": not dirty_files,
        "current_branch": current_branch,
        "head_sha": head_sha,
        "dirty_files": dirty_files,
    }


def is_head_ancestor_of_ref(project_root: str, head_sha: str | None, base_ref: str) -> bool | None:
    if not head_sha:
        return None
    res = subprocess.run(
        ["git", "-C", project_root, "merge-base", "--is-ancestor", head_sha, base_ref],
        capture_output=True,
        text=True,
        check=False,
    )
    if res.returncode == 0:
        return True
    if res.returncode == 1:
        return False
    return None


def assess_remote_branch_cleanup(
    *,
    pr_number: int,
    head_branch: str,
    merged: bool,
    remote_branch_exists: bool,
    open_pr_heads: set[str],
    protected_branches: frozenset[str] | None = None,
    head_on_master: bool | None,
    delete_capability_allowed: bool,
    active_lock: bool,
) -> dict[str, Any]:
    protected = protected_branches or PROTECTED_BRANCHES
    reasons: list[str] = []
    if not merged:
        reasons.append("PR is not merged")
    if not remote_branch_exists:
        reasons.append("remote branch already absent")
    if head_branch in protected:
        reasons.append(f"branch '{head_branch}' is protected")
    if head_branch in open_pr_heads:
        reasons.append("an open PR still references this head branch")
    if active_lock:
        reasons.append("active issue lock references this branch")
    if head_on_master is False:
        reasons.append("PR head is not an ancestor of master")
    if not delete_capability_allowed:
        reasons.append("delete_branch capability is not allowed in the active profile")

    safe = merged and remote_branch_exists and not reasons
    return {
        "pr_number": pr_number,
        "head_branch": head_branch,
        "remote_branch_exists": remote_branch_exists,
        "safe_to_delete_remote": safe,
        "block_reasons": reasons,
        "recommended_action": "delete_remote_branch" if safe else "keep_remote_branch",
    }


def assess_local_worktree_cleanup(
    *,
    pr_number: int,
    head_branch: str,
    merged: bool,
    worktree_state: dict[str, Any],
    active_lock: bool,
) -> dict[str, Any]:
    reasons: list[str] = []
    exists = bool(worktree_state.get("exists"))
    if not merged:
        reasons.append("PR is not merged")
    reasons.extend(worktree_state.get("alias_block_reasons") or [])
    if not exists:
        reasons.append("local worktree not present")
    if exists and worktree_state.get("clean") is False:
        dirty = worktree_state.get("dirty_files") or []
        reasons.append(
            "local worktree has tracked edits"
            + (f" ({', '.join(dirty)})" if dirty else "")
        )
    if exists:
        current_branch = worktree_state.get("current_branch")
        if (
            current_branch
            and current_branch != head_branch
            and not worktree_state.get("alias_verified")
        ):
            reasons.append(
                f"worktree branch '{current_branch}' does not match PR head '{head_branch}'"
            )
    if active_lock:
        reasons.append("active issue lock references this branch")

    safe = merged and exists and not reasons
    return {
        "pr_number": pr_number,
        "head_branch": head_branch,
        "worktree_path": worktree_state.get("worktree_path"),
        "expected_worktree_path": worktree_state.get("expected_worktree_path"),
        "discovered_worktree_path": worktree_state.get("discovered_worktree_path"),
        "match_type": worktree_state.get("match_type"),
        "candidate_paths": worktree_state.get("candidate_paths") or [],
        "worktree_exists": exists,
        "worktree_clean": worktree_state.get("clean"),
        "safe_to_remove_worktree": safe,
        "block_reasons": reasons,
        "recommended_action": "remove_local_worktree" if safe else "keep_local_worktree",
    }


def build_pr_cleanup_entry(
    *,
    pr: dict[str, Any],
    project_root: str,
    open_pr_heads: set[str],
    remote_branch_exists: bool,
    head_on_master: bool | None,
    delete_capability_allowed: bool,
    issue_lock_path: str | None = None,
    protected_branches: frozenset[str] | None = None,
    target_ref: str | None = None,
) -> dict[str, Any]:
    pr_number = int(pr["number"])
    head_branch = pr.get("head") or ""
    if isinstance(head_branch, dict):
        head_branch = head_branch.get("ref") or ""
    title = pr.get("title") or ""
    body = pr.get("body") or ""
    merged = bool(pr.get("merged_at"))
    issue_number = extract_linked_issue(title, body) or extract_issue_marker(head_branch)
    head_payload = pr.get("head") if isinstance(pr.get("head"), dict) else {}
    pr_head_sha = head_payload.get("sha") if isinstance(head_payload, dict) else None
    worktree_state = resolve_cleanup_worktree_state(
        project_root=project_root,
        head_branch=head_branch,
        issue_number=issue_number,
        pr_head_sha=pr_head_sha,
        target_ref=target_ref,
    )
    active_lock = has_active_issue_lock(head_branch, issue_lock_path)

    remote = assess_remote_branch_cleanup(
        pr_number=pr_number,
        head_branch=head_branch,
        merged=merged,
        remote_branch_exists=remote_branch_exists,
        open_pr_heads=open_pr_heads,
        protected_branches=protected_branches,
        head_on_master=head_on_master,
        delete_capability_allowed=delete_capability_allowed,
        active_lock=active_lock,
    )
    local = assess_local_worktree_cleanup(
        pr_number=pr_number,
        head_branch=head_branch,
        merged=merged,
        worktree_state=worktree_state,
        active_lock=active_lock,
    )
    return {
        "pr_number": pr_number,
        "issue_number": issue_number,
        "title": title,
        "head_branch": head_branch,
        "merge_commit_sha": pr.get("merge_commit_sha"),
        "merged_at": pr.get("merged_at"),
        "merged": merged,
        "remote_branch": remote,
        "local_worktree": local,
    }


def build_reconciliation_report(
    *,
    project_root: str,
    closed_prs: list[dict[str, Any]],
    open_prs: list[dict[str, Any]],
    remote_branch_exists: dict[str, bool],
    head_on_master: dict[int, bool | None],
    delete_capability_allowed: bool,
    issue_lock_path: str | None = None,
    protected_branches: frozenset[str] | None = None,
    target_ref: str | None = None,
    active_reviewer_leases: dict[int, bool] | None = None,
    pr_states: dict[int, dict[str, Any]] | None = None,
) -> dict[str, Any]:
    open_heads = collect_open_pr_heads(open_prs)
    entries: list[dict[str, Any]] = []
    for pr in closed_prs or []:
        if not pr.get("merged_at"):
            continue
        pr_number = int(pr["number"])
        head = pr.get("head") or ""
        if isinstance(head, dict):
            head = head.get("ref") or ""
        entries.append(
            build_pr_cleanup_entry(
                pr=pr,
                project_root=project_root,
                open_pr_heads=open_heads,
                remote_branch_exists=bool(remote_branch_exists.get(head)),
                head_on_master=head_on_master.get(pr_number),
                delete_capability_allowed=delete_capability_allowed,
                issue_lock_path=issue_lock_path,
                protected_branches=protected_branches,
                target_ref=target_ref,
            )
        )

    # #534: reviewer scratch worktrees are reported separately from author cleanup.
    lease_map = active_reviewer_leases or {}
    state_map = pr_states or {}
    open_pr_numbers = {
        int(pr["number"]) for pr in (open_prs or []) if pr.get("number") is not None
    }
    closed_by_number = {
        int(pr["number"]): pr for pr in (closed_prs or []) if pr.get("number") is not None
    }
    scratch_entries: list[dict[str, Any]] = []
    for scratch in discover_reviewer_scratch_worktrees(project_root):
        pr_number = int(scratch["pr_number"])
        state_info = state_map.get(pr_number) or {}
        closed_pr = closed_by_number.get(pr_number)
        if closed_pr is not None:
            pr_merged = bool(closed_pr.get("merged_at") or closed_pr.get("merged"))
            pr_closed = True
        elif pr_number in open_pr_numbers:
            pr_merged = False
            pr_closed = False
        else:
            pr_merged = bool(state_info.get("merged"))
            pr_closed = bool(state_info.get("closed") or state_info.get("merged"))
        worktree_path = scratch["worktree_path"]
        worktree_state = read_local_worktree_state(worktree_path)
        worktree_state["worktree_path"] = worktree_path
        # Prefer live branch from state (git) over porcelain branch field.
        assessment = assess_reviewer_scratch_cleanup(
            pr_number=pr_number,
            worktree_path=worktree_path,
            folder_name=scratch["folder_name"],
            pr_merged=pr_merged,
            pr_closed=pr_closed,
            worktree_state=worktree_state,
            active_reviewer_lease=bool(lease_map.get(pr_number)),
        )
        scratch_entries.append(assessment)

    return {
        "project_root": os.path.realpath(project_root),
        "merged_pr_count": len(entries),
        "entries": entries,
        "reviewer_scratch_entries": scratch_entries,
        "reviewer_scratch_count": len(scratch_entries),
        "dry_run": True,
        "executed": False,
    }


def remove_local_worktree(
    project_root: str,
    branch: str,
    worktree_path: str | None = None,
) -> dict[str, Any]:
    worktree_path = worktree_path or resolve_worktree_path(project_root, branch)
    if not os.path.isdir(worktree_path):
        return {
            "success": False,
            "performed": False,
            "message": f"worktree not found: {worktree_path}",
        }
    res = subprocess.run(
        ["git", "-C", project_root, "worktree", "remove", worktree_path],
        capture_output=True,
        text=True,
        check=False,
    )
    if res.returncode != 0:
        return {
            "success": False,
            "performed": False,
            "message": (res.stderr or res.stdout or "worktree remove failed").strip(),
        }
    return {
        "success": True,
        "performed": True,
        "message": f"removed worktree {worktree_path}",
        "worktree_path": worktree_path,
    }


def remove_reviewer_scratch_worktree(
    project_root: str,
    worktree_path: str,
) -> dict[str, Any]:
    """Remove a reviewer scratch worktree by absolute path (#534).

    Fail closed unless the path is a direct ``branches/review-pr*`` child of
    *project_root*. Never routes through author branch-name derivation.
    """
    root = os.path.realpath(project_root)
    branches_root = os.path.realpath(os.path.join(root, "branches"))
    real = os.path.realpath((worktree_path or "").strip())
    folder = os.path.basename(real)
    if os.path.dirname(real) != branches_root:
        return {
            "success": False,
            "performed": False,
            "worktree_kind": "reviewer_scratch",
            "message": (
                "reviewer scratch path must be a direct child of "
                f"{branches_root}; got {real}"
            ),
        }
    if parse_reviewer_scratch_folder(folder) is None:
        return {
            "success": False,
            "performed": False,
            "worktree_kind": "reviewer_scratch",
            "message": f"path is not a reviewer scratch folder: {folder}",
        }
    if not os.path.isdir(real):
        return {
            "success": False,
            "performed": False,
            "worktree_kind": "reviewer_scratch",
            "message": f"worktree not found: {real}",
        }
    res = subprocess.run(
        ["git", "-C", project_root, "worktree", "remove", real],
        capture_output=True,
        text=True,
        check=False,
    )
    if res.returncode != 0:
        return {
            "success": False,
            "performed": False,
            "worktree_kind": "reviewer_scratch",
            "message": (res.stderr or res.stdout or "worktree remove failed").strip(),
            "worktree_path": real,
        }
    return {
        "success": True,
        "performed": True,
        "worktree_kind": "reviewer_scratch",
        "message": f"removed reviewer scratch worktree {real}",
        "worktree_path": real,
        "author_worktree_cleanup": False,
    }
