"""Phase 6 contribution — PR opener via `gh` CLI.

Wraps `gh pr create --draft` with a Genesis-shaped template body:
install metadata, sanitizer summary, review result, version status.

The non-dry-run flow orchestrates a full fork-based PR:

    1. Resolve target repo (arg or `gh repo view`)
    2. Resolve contributor login (`gh api user`)
    3. Ensure fork exists (`gh api repos/<login>/<repo>` check,
       then POST `/repos/<target>/forks` if missing)
    4. Create a detached git worktree at upstream_sha
    5. Branch it + cherry-pick the contributor's fix
    6. Push the branch to the fork URL
    7. `gh pr create --repo <target> --head <login>:<branch>`
    8. Always clean up the temp worktree (success or failure)

Why `gh` and not PyGithub: auth reuses whatever the user already
configured (`gh auth login`), no new credential plumbing, and `gh`
sets up a git credential helper so `git push <fork-url>` works
without further setup.

Why a detached worktree (and not the caller's working copy): the
contributor is in the middle of their own work. We MUST NOT touch
their current branch, index, or working tree. A throwaway detached
worktree off upstream HEAD gives us an isolated space to cherry-pick
without disturbing anything.
"""
from __future__ import annotations

import logging
import re
import shutil
import subprocess
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path

from .findings import (
    InstallInfo,
    ReviewResult,
    SanitizerResult,
    VersionGateResult,
)

logger = logging.getLogger(__name__)

PR_LABEL = "community-submission"
BASE_BRANCH = "main"
BRANCH_PREFIX = "community"

# Belt-and-suspenders SHA validator. cli.py already validates, but
# this module is importable on its own so we re-validate before any
# git subcommand that takes a SHA as a positional.
_SHA_RE = re.compile(r"^[0-9a-fA-F]{4,40}$")


@dataclass
class PRCreationResult:
    ok: bool
    url: str | None = None
    branch: str | None = None
    error: str | None = None
    body: str = ""  # the composed body, useful for display on failure


def build_pr_body(
    *,
    install: InstallInfo,
    source_sha: str,
    subject: str,
    version_display: str,
    version_gate: VersionGateResult,
    sanitizer: SanitizerResult,
    review: ReviewResult,
) -> str:
    """Compose the PR body with mandatory metadata fields.

    All of these fields are MANDATORY per the Phase 6 plan. If a
    section is empty or unavailable, we say so explicitly — never
    silently omit. Jay uses these to triage.
    """
    install_hash = install.install_id[:8]
    short_sha = source_sha[:12] if source_sha else "unknown"

    if version_gate.version_match:
        version_status = "matches upstream HEAD"
    elif version_gate.upstream_commit_count > 0:
        version_status = (
            f"{version_gate.upstream_commit_count} commits behind upstream HEAD"
        )
        if version_gate.matched_sha:
            version_status += (
                f" — possible upstream match at {version_gate.matched_sha} "
                f"(LLM confidence {version_gate.confidence}%)"
            )
    else:
        version_status = "unknown (upstream log unavailable)"

    scanners = ", ".join(sanitizer.scanners_run) or "none"
    finding_count = len(sanitizer.findings)
    sanitizer_summary = f"{finding_count} finding(s); scanners: {scanners}"

    if review.available:
        review_line = (
            f"{review.reviewer}={'pass' if review.passed else 'fail'} "
            f"({review.finding_count} finding(s))"
        )
    else:
        review_line = "unavailable"

    lines = [
        "## Community contribution",
        "",
        f"- **Subject:** {subject}",
        f"- **Contributor install version:** {version_display}",
        f"- **Source commit:** `{short_sha}`",
        f"- **Install ID:** `{install_hash}`",
        f"- **Version status:** {version_status}",
        f"- **Sanitizer:** {sanitizer_summary}",
        f"- **Review:** {review_line}",
        "",
        "### Review details",
        "",
        (review.summary or review.raw[:2000] or "_(none)_"),
        "",
        "### About this PR",
        "",
        "This PR was generated by Phase 6 of the Genesis community "
        "contribution pipeline. The fix was committed to the contributor's "
        "local Genesis install and offered upstream via "
        "`genesis contribute <sha>` (or the post-commit conversational "
        "offer). The sanitizer is fail-closed: any finding above blocks "
        "submission, so a non-empty PR body means 0 blocking findings "
        "at send time. Reviews are non-blocking signal — merge decisions "
        "stay with maintainers.",
    ]
    return "\n".join(lines)


def branch_name(install: InstallInfo, source_sha: str) -> str:
    """Return the target branch name: community/<install-8>-<short-sha>."""
    install_prefix = install.install_id[:8]
    short_sha = (source_sha[:7] if source_sha else "nosha")
    return f"{BRANCH_PREFIX}/{install_prefix}-{short_sha}"


def _check_gh_available() -> str | None:
    """Return gh binary path or None."""
    return shutil.which("gh")


def _check_gh_auth() -> tuple[bool, str]:
    """Check `gh auth status`. Returns (ok, message)."""
    try:
        proc = subprocess.run(
            ["gh", "auth", "status"],
            capture_output=True,
            text=True,
            timeout=10,
            check=False,
        )
    except (subprocess.TimeoutExpired, FileNotFoundError) as e:
        return False, f"gh auth check failed: {e}"
    if proc.returncode != 0:
        err = (proc.stderr or proc.stdout or "").strip()
        return False, err or "gh not authenticated — run `gh auth login`"
    return True, "ok"


def _gh_current_user() -> str | None:
    """Resolve the authenticated GitHub login via `gh api user`."""
    try:
        proc = subprocess.run(
            ["gh", "api", "user", "--jq", ".login"],
            capture_output=True, text=True, timeout=10, check=False,
        )
    except (FileNotFoundError, subprocess.TimeoutExpired) as e:
        logger.error("gh api user failed: %s", e, exc_info=True)
        return None
    if proc.returncode != 0:
        logger.error(
            "gh api user rc=%s stderr=%s", proc.returncode, proc.stderr.strip(),
        )
        return None
    return proc.stdout.strip() or None


def resolve_target_repo(repo_path: Path | None) -> str | None:
    """Resolve `owner/repo` for the current working directory via gh.

    Public so cli.py can surface the resolved value in the consent
    prompt before the user approves the PR.
    """
    try:
        proc = subprocess.run(
            ["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"],
            capture_output=True, text=True, timeout=10, check=False,
            cwd=str(repo_path) if repo_path else None,
        )
    except (FileNotFoundError, subprocess.TimeoutExpired) as e:
        logger.error("gh repo view failed: %s", e, exc_info=True)
        return None
    if proc.returncode != 0:
        return None
    return proc.stdout.strip() or None


def _repo_name_from_target(target_repo: str) -> str:
    """`owner/repo` -> `repo`. Raises ValueError if malformed."""
    if "/" not in target_repo:
        raise ValueError(f"target_repo must be owner/repo, got: {target_repo!r}")
    return target_repo.split("/", 1)[1]


def _fork_exists(login: str, repo_name: str, target_repo: str | None = None) -> bool:
    """Return True iff `<login>/<repo_name>` exists on GitHub AND is a fork
    of `target_repo` (when provided).

    The parent-name check (codex review I1) prevents a false positive when
    the contributor happens to have a personal repo with the same name
    that ISN'T a fork of the upstream — pushing into it and opening a
    cross-account PR would otherwise fail with an opaque error.
    """
    jq = ".id" if target_repo is None else ".parent.full_name // \"\""
    try:
        proc = subprocess.run(
            ["gh", "api", f"repos/{login}/{repo_name}", "--jq", jq],
            capture_output=True, text=True, timeout=10, check=False,
        )
    except (FileNotFoundError, subprocess.TimeoutExpired) as e:
        logger.error("gh api fork-check failed: %s", e, exc_info=True)
        return False
    if proc.returncode != 0:
        # Expected on first run — repo doesn't exist yet. Don't log noise.
        logger.debug(
            "fork check %s/%s: rc=%s", login, repo_name, proc.returncode,
        )
        return False
    if target_repo is None:
        return True
    parent = proc.stdout.strip()
    return parent.lower() == target_repo.lower()


def _ensure_fork(target_repo: str, login: str) -> tuple[bool, str]:
    """Ensure a fork of `target_repo` exists under `login`.

    Uses the direct API (`gh api -X POST repos/<target>/forks`) rather
    than `gh repo fork` so we never hit the interactive "clone?" prompt.
    GitHub creates the fork asynchronously, so we poll briefly for it
    to become visible under the authenticated user.
    """
    try:
        repo_name = _repo_name_from_target(target_repo)
    except ValueError as e:
        return False, str(e)

    # Strict check (codex I1): verify it's actually a fork OF the target
    # repo, not just any repo with the same name owned by the contributor.
    if _fork_exists(login, repo_name, target_repo=target_repo):
        return True, ""

    # Distinguish "fork doesn't exist (create it)" from "name collision
    # with a non-fork repo (refuse — caller would push into the wrong repo)".
    if _fork_exists(login, repo_name, target_repo=None):
        return False, (
            f"{login}/{repo_name} exists on GitHub but is NOT a fork of "
            f"{target_repo}. Refusing to push into it. Rename or delete "
            "your existing repo, then retry."
        )

    try:
        proc = subprocess.run(
            ["gh", "api", "-X", "POST", f"repos/{target_repo}/forks"],
            capture_output=True, text=True, timeout=30, check=False,
        )
    except (FileNotFoundError, subprocess.TimeoutExpired) as e:
        logger.error("gh api fork-create failed: %s", e, exc_info=True)
        return False, f"fork API call failed: {e}"
    if proc.returncode != 0:
        err = (proc.stderr or proc.stdout or "unknown fork error").strip()
        return False, f"fork API returned error: {err}"

    # Fork creation is async on GitHub's side; poll up to 10s.
    for _ in range(10):
        if _fork_exists(login, repo_name, target_repo=target_repo):
            return True, ""
        time.sleep(1)
    return False, f"fork created but {login}/{repo_name} not visible after 10s"


def _fork_url(login: str, target_repo: str) -> str:
    """Compose the HTTPS clone URL for the contributor's fork."""
    repo_name = _repo_name_from_target(target_repo)
    return f"https://github.com/{login}/{repo_name}.git"


def _ensure_sha_reachable(repo_path: Path, sha: str) -> tuple[bool, str]:
    """Ensure `sha` is in repo_path's local object database.

    Codex C2 fix: `git worktree add` fails opaquely if the SHA isn't
    locally reachable. The CLI's `fetch_upstream_head` uses `ls-remote`
    which returns a SHA without fetching the underlying objects, so a
    contributor who hasn't `git fetch`-ed recently can hit this case.
    On a miss, we run a single `git fetch` (no remote arg → uses
    origin) and re-check.
    """
    have = subprocess.run(
        ["git", "cat-file", "-e", f"{sha}^{{commit}}"],
        capture_output=True, text=True,
        cwd=str(repo_path), timeout=10, check=False,
    )
    if have.returncode == 0:
        return True, ""
    fetch = subprocess.run(
        ["git", "fetch", "--quiet"],
        capture_output=True, text=True,
        cwd=str(repo_path), timeout=60, check=False,
    )
    if fetch.returncode != 0:
        return False, (
            f"sha {sha[:12]} not in local objects and `git fetch` failed: "
            f"{fetch.stderr.strip()}"
        )
    have2 = subprocess.run(
        ["git", "cat-file", "-e", f"{sha}^{{commit}}"],
        capture_output=True, text=True,
        cwd=str(repo_path), timeout=10, check=False,
    )
    if have2.returncode == 0:
        return True, ""
    return False, (
        f"sha {sha[:12]} not reachable in local objects even after fetch"
    )


def _prepare_and_push_branch(
    *,
    repo_path: Path,
    upstream_sha: str,
    source_sha: str,
    branch: str,
    fork_url: str,
) -> tuple[bool, str]:
    """Create a detached worktree at upstream_sha, cherry-pick source_sha,
    and push the resulting branch to `fork_url`.

    Runs entirely in a tempdir under the system temp root so the
    contributor's working copy is never touched. Cleanup is guaranteed
    via try/finally even when cherry-pick or push fails.

    Codex review notes (residue prevention):
    - We `git worktree prune` at the top so any ghost entries from a
      prior crashed run (process killed before the `finally` ran) are
      scrubbed before we add a new one. This bounds worktree pollution
      in the contributor's main repo.
    - The push uses `--force-with-lease` so a re-run for the same fix
      (deterministic branch name) overwrites the contributor's own
      stale fork branch but refuses to clobber concurrent updates.
    """
    for name, sha in (("upstream_sha", upstream_sha), ("source_sha", source_sha)):
        if not _SHA_RE.match(sha or ""):
            return False, f"invalid {name}: {sha!r}"

    # C2 fix: make sure upstream_sha is locally reachable before we try
    # to add a worktree at it. Same for source_sha (defensive — should
    # always be present since the contributor just committed it).
    ok, msg = _ensure_sha_reachable(repo_path, upstream_sha)
    if not ok:
        return False, f"upstream sha unreachable: {msg}"
    ok, msg = _ensure_sha_reachable(repo_path, source_sha)
    if not ok:
        return False, f"source sha unreachable: {msg}"

    # Opportunistic prune of any stale `genesis-contrib-*` worktree
    # entries from prior crashed runs. Bounded — never errors out.
    subprocess.run(
        ["git", "worktree", "prune", "--expire", "now"],
        capture_output=True, text=True,
        cwd=str(repo_path), timeout=10, check=False,
    )

    wt_dir = Path(tempfile.mkdtemp(prefix="genesis-contrib-"))
    worktree_added = False
    try:
        # 1. detached worktree at upstream HEAD
        proc = subprocess.run(
            [
                "git", "worktree", "add", "--detach",
                str(wt_dir), upstream_sha, "--",
            ],
            capture_output=True, text=True,
            cwd=str(repo_path), timeout=30, check=False,
        )
        if proc.returncode != 0:
            return False, f"worktree add failed: {proc.stderr.strip()}"
        worktree_added = True

        # 2. branch off the detached head. -B (vs -b) overwrites a
        # local branch left over from a prior run — branches survive
        # `git worktree remove` since they live in the parent repo's
        # refs/heads. Without -B the second run of an idempotent retry
        # fails with "a branch named X already exists".
        proc = subprocess.run(
            ["git", "checkout", "-B", branch],
            capture_output=True, text=True,
            cwd=str(wt_dir), timeout=15, check=False,
        )
        if proc.returncode != 0:
            return False, f"branch checkout failed: {proc.stderr.strip()}"

        # 3. cherry-pick the fix
        proc = subprocess.run(
            ["git", "cherry-pick", source_sha],
            capture_output=True, text=True,
            cwd=str(wt_dir), timeout=30, check=False,
        )
        if proc.returncode != 0:
            # Abort so the worktree is clean enough to remove
            subprocess.run(
                ["git", "cherry-pick", "--abort"],
                capture_output=True, text=True,
                cwd=str(wt_dir), timeout=10, check=False,
            )
            return False, (
                f"cherry-pick failed (conflicts likely): "
                f"{proc.stderr.strip() or proc.stdout.strip()}"
            )

        # 4. push to fork. --force-with-lease handles the re-run case
        # (same install_id + same source_sha → same branch name) by
        # overwriting our own stale branch but refusing to stomp on
        # concurrent updates from another machine.
        #
        # We push to a URL (not a configured remote), so git has no
        # remote-tracking ref to derive the lease's expected SHA from.
        # On modern git, an implicit lease in that situation fails with
        # "stale info" rather than falling back to no-protection. So we
        # query the fork ourselves via `git ls-remote` and pass the
        # explicit `=<ref>:<expected>` form. An empty `<expected>` is
        # the canonical "ref must not exist on remote" lease.
        ls_proc = subprocess.run(
            ["git", "ls-remote", fork_url, f"refs/heads/{branch}"],
            capture_output=True, text=True,
            cwd=str(wt_dir), timeout=30, check=False,
        )
        if ls_proc.returncode != 0:
            # Auth failure, network blip, DNS, rate limit. Surface
            # cleanly instead of falling through to an empty lease
            # that would then produce a confusing "stale info" error
            # on the push if the branch happens to exist.
            return False, f"ls-remote failed: {ls_proc.stderr.strip()}"
        expected_sha = ""
        if ls_proc.stdout.strip():
            expected_sha = ls_proc.stdout.split()[0]
        lease_arg = f"--force-with-lease=refs/heads/{branch}:{expected_sha}"
        proc = subprocess.run(
            [
                "git", "push", lease_arg,
                fork_url, f"HEAD:refs/heads/{branch}",
            ],
            capture_output=True, text=True,
            cwd=str(wt_dir), timeout=120, check=False,
        )
        if proc.returncode != 0:
            return False, f"push to fork failed: {proc.stderr.strip()}"

        return True, ""
    finally:
        if worktree_added:
            subprocess.run(
                ["git", "worktree", "remove", "--force", str(wt_dir)],
                capture_output=True, text=True,
                cwd=str(repo_path), timeout=15, check=False,
            )
        # If `git worktree remove` didn't fully scrub (rare on errors
        # before any commit was made), clean the dir best-effort.
        if wt_dir.exists():
            shutil.rmtree(str(wt_dir), ignore_errors=True)


def create_pr(
    *,
    install: InstallInfo,
    source_sha: str,
    subject: str,
    version_display: str,
    version_gate: VersionGateResult,
    sanitizer: SanitizerResult,
    review: ReviewResult,
    upstream_sha: str | None = None,
    target_repo: str | None = None,
    repo_path: Path | None = None,
    dry_run: bool = False,
) -> PRCreationResult:
    """Open a draft PR via the full fork + cherry-pick + push + gh flow.

    Args:
        install: Install identity for pseudonymous attribution.
        source_sha: The contributor's commit SHA (their fix).
        subject: Commit subject line → becomes PR title.
        version_display: `read_install_version@short_sha` string.
        version_gate: Result from check_version_gate().
        sanitizer: Result from scan_diff() — must have ok=True.
        review: Result from run_review_chain().
        upstream_sha: Upstream HEAD the fix will be cherry-picked on top
            of. REQUIRED for non-dry-run. Determined by the CLI via
            `ls-remote origin HEAD` and passed through.
        target_repo: `owner/repo` for the PR. Falls back to
            `gh repo view` from the working directory.
        repo_path: Repo to run `gh` and `git` in. Defaults to cwd.
        dry_run: If True, build body + branch name and return without
            any side effects (no fork, no cherry-pick, no push, no PR).

    Returns:
        PRCreationResult with `ok=True` and the PR URL on success, or
        a descriptive error on any failure in the pipeline. On failure
        the `body` field is populated so the CLI can print it for the
        user.
    """
    if not sanitizer.ok:
        return PRCreationResult(
            ok=False,
            error="Refusing to create PR: sanitizer reported blocking findings.",
        )

    body = build_pr_body(
        install=install,
        source_sha=source_sha,
        subject=subject,
        version_display=version_display,
        version_gate=version_gate,
        sanitizer=sanitizer,
        review=review,
    )
    branch = branch_name(install, source_sha)

    if _check_gh_available() is None:
        return PRCreationResult(
            ok=False,
            branch=branch,
            body=body,
            error=(
                "`gh` CLI not found on PATH. Install from https://cli.github.com "
                "and run `gh auth login`."
            ),
        )

    if dry_run:
        return PRCreationResult(
            ok=True,
            branch=branch,
            body=body,
            url=None,
            error="dry-run: no PR created",
        )

    # --- Full non-dry-run flow ---

    if not upstream_sha or not _SHA_RE.match(upstream_sha):
        return PRCreationResult(
            ok=False, branch=branch, body=body,
            error=(
                "upstream_sha is required for non-dry-run PR creation and "
                f"must be a 4-40 char hex SHA (got: {upstream_sha!r})"
            ),
        )

    effective_target = target_repo or resolve_target_repo(repo_path)
    if not effective_target:
        return PRCreationResult(
            ok=False, branch=branch, body=body,
            error=(
                "could not resolve target repo; pass --target-repo owner/repo "
                "or run from a clone with a gh-recognized remote"
            ),
        )

    ok, msg = _check_gh_auth()
    if not ok:
        return PRCreationResult(
            ok=False, branch=branch, body=body, error=f"gh auth: {msg}",
        )

    login = _gh_current_user()
    if not login:
        return PRCreationResult(
            ok=False, branch=branch, body=body,
            error="could not resolve gh user (gh api user failed)",
        )

    ok, msg = _ensure_fork(effective_target, login)
    if not ok:
        return PRCreationResult(
            ok=False, branch=branch, body=body,
            error=f"fork setup failed: {msg}",
        )

    fork_url = _fork_url(login, effective_target)

    ok, msg = _prepare_and_push_branch(
        repo_path=repo_path or Path.cwd(),
        upstream_sha=upstream_sha,
        source_sha=source_sha,
        branch=branch,
        fork_url=fork_url,
    )
    if not ok:
        return PRCreationResult(
            ok=False, branch=branch, body=body,
            error=f"branch push failed: {msg}",
        )

    cmd = [
        "gh", "pr", "create",
        "--draft",
        "--repo", effective_target,
        "--base", BASE_BRANCH,
        "--head", f"{login}:{branch}",
        "--title", subject,
        "--body", body,
        "--label", PR_LABEL,
    ]

    try:
        proc = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            cwd=str(repo_path) if repo_path else None,
            timeout=60,
            check=False,
        )
    except subprocess.TimeoutExpired:
        return PRCreationResult(
            ok=False, branch=branch, body=body,
            error="gh pr create timed out after 60s",
        )
    except FileNotFoundError:
        return PRCreationResult(
            ok=False, branch=branch, body=body,
            error="gh binary disappeared between check and execution",
        )

    if proc.returncode != 0:
        err = (proc.stderr or proc.stdout or "unknown gh error").strip()
        logger.error("gh pr create failed rc=%s: %s", proc.returncode, err)
        return PRCreationResult(
            ok=False, branch=branch, body=body, error=err,
        )

    url = proc.stdout.strip().splitlines()[-1] if proc.stdout.strip() else None
    return PRCreationResult(ok=True, branch=branch, body=body, url=url)
