"""Publish an unpublished local commit on a registered issue worktree (#812 AC20).

Entry point B of #812 is the state where an author's work has already advanced
to a local commit: the worktree is registered, clean, on the issue branch, and
carries the only copy of the implementation, but the branch has never been
published. That state deadlocks, because two individually correct predicates
close a cycle:

* ``issue_lock_renewal.assess_exact_owner_lease_renewal`` refuses to renew an
  expired lease without an observable remote head — an unpublished branch has
  none.
* Every publication path (``gitea_commit_files``, ``gitea_create_pr``) derives
  its workspace from the author issue lock under #618, so nothing can create
  that remote head without first holding the lock.

This module supplies the missing operation: it publishes an *already committed*
local head to the remote branch, so exact-owner renewal has the evidence it
requires. It deliberately does **not** renew, reclaim, rebind, or clear any
lock. Publication is the whole of its authority.

Why this is not a lock bypass
-----------------------------
The operation can only publish a branch whose **durable issue-lock record
already names the caller as claimant**. Ownership is read from the lock file on
disk (``issue_lock_store``), never from a caller-supplied flag, so the tool
cannot manufacture a claim it does not already hold. Nothing here weakens the
#510/#618/#713 guards: a dirty tree, an unregistered worktree, a foreign
claimant, a changed HEAD, or a divergent remote head each refuse, exactly as
they do today. The only thing this adds is the ability to make an existing,
owned, committed, clean branch observable on the remote.

Separation of records (#812 AC23)
---------------------------------
The durable **issue-lock file** and the control-plane **workflow lease** are
distinct records. This module reads the former as ownership evidence and writes
neither. Publishing changes remote git state only; no lock is renewed,
abandoned, reclaimed, or generation-bumped here.

Process evidence (#812 AC24)
----------------------------
Liveness of the lock's recorded pid is **not consulted**. That is deliberate:
the recorded pid routinely belongs to the long-running MCP daemon rather than to
an active author client, and the existing reclaim predicate
(``assess_expired_lock_reclaim``) can never be satisfied while that daemon runs.
Publication does not require the recording process to be dead, so this module
never asserts, infers, or depends on a process being dead. Ownership is proven
by identity and profile match against the recorded claimant instead.
"""

from __future__ import annotations

import hashlib
import os
import re
import subprocess

from reviewer_worktree import parse_dirty_tracked_files
from stable_branch_push_guard import is_stable_ref, redact_command

# Assessment outcomes.
PUBLISH_SANCTIONED = "publish_sanctioned"
ALREADY_PUBLISHED = "already_published"
REFUSED = "refused"

#: Implementation branches must stay traceable to their issue (#713 lineage).
ISSUE_BRANCH_RE = re.compile(r"^(fix|feat|docs|chore)/issue-(\d+)-.+$")

_SHA_RE = re.compile(r"^[0-9a-f]{40}$")


def _text(value: object) -> str:
    return value.strip() if isinstance(value, str) else ""


def _realpath(value: str | None) -> str | None:
    path = _text(value)
    return os.path.realpath(path) if path else None


def parse_untracked_files(porcelain_status: str) -> list[str]:
    """Return untracked paths from ``git status --porcelain`` output.

    ``reviewer_worktree.parse_dirty_tracked_files`` deliberately skips ``??``
    entries. Publication needs both halves: an untracked file in the worktree is
    unpublished content that the commit does not carry, so publishing would
    silently leave it behind.
    """
    untracked: list[str] = []
    for line in (porcelain_status or "").splitlines():
        if not line.startswith("??"):
            continue
        path = line[2:].strip()
        if path:
            untracked.append(path)
    return untracked


def hash_worktree_files(worktree_path: str, paths) -> dict[str, str | None]:
    """SHA-256 each path under *worktree_path*; ``None`` when unreadable."""
    root = _text(worktree_path)
    hashes: dict[str, str | None] = {}
    for rel in paths or ():
        rel_text = _text(rel)
        if not rel_text:
            continue
        full = os.path.join(root, rel_text)
        try:
            with open(full, "rb") as handle:
                digest = hashlib.sha256()
                for chunk in iter(lambda: handle.read(65536), b""):
                    digest.update(chunk)
            hashes[rel_text] = digest.hexdigest()
        except OSError:
            hashes[rel_text] = None
    return hashes


def read_remote_branch_head(
    worktree_path: str, remote_name: str, branch_name: str
) -> dict:
    """Observe the remote head for *branch_name*, read-only.

    ``probe_ok`` False means git could not answer at all. That is kept distinct
    from "the branch does not exist": an unobservable remote must fail closed
    rather than be mistaken for an absent branch, because the two lead to
    opposite dispositions.
    """
    path = _text(worktree_path)
    remote = _text(remote_name)
    branch = _text(branch_name)
    result: dict = {
        "probe_ok": False,
        "remote_branch_exists": False,
        "remote_head_sha": None,
        "reasons": [],
    }
    if not (path and remote and branch):
        result["reasons"].append(
            "remote head probe requires a worktree path, remote name, and branch"
        )
        return result
    try:
        res = subprocess.run(
            ["git", "-C", path, "ls-remote", remote, f"refs/heads/{branch}"],
            capture_output=True,
            text=True,
            check=False,
        )
    except OSError as exc:  # git unavailable — fail closed, never assume absent
        result["reasons"].append(f"remote head probe could not run: {exc}")
        return result
    if res.returncode != 0:
        result["reasons"].append(
            f"remote head probe failed for '{branch}' on remote '{remote}'"
        )
        return result

    result["probe_ok"] = True
    for line in (res.stdout or "").splitlines():
        parts = line.split()
        if len(parts) >= 2 and parts[1] == f"refs/heads/{branch}":
            result["remote_branch_exists"] = True
            result["remote_head_sha"] = parts[0].strip()
            break
    return result


def read_is_ancestor(
    worktree_path: str, ancestor_sha: str, descendant_sha: str
) -> dict:
    """Observe whether *ancestor_sha* is an ancestor of *descendant_sha*."""
    path = _text(worktree_path)
    ancestor = _text(ancestor_sha)
    descendant = _text(descendant_sha)
    result: dict = {"probe_ok": False, "is_ancestor": False, "reasons": []}
    if not (path and ancestor and descendant):
        result["reasons"].append(
            "ancestry probe requires a worktree path and both commit SHAs"
        )
        return result
    try:
        present = subprocess.run(
            ["git", "-C", path, "rev-parse", "--verify", "--quiet",
             f"{ancestor}^{{commit}}"],
            capture_output=True, text=True, check=False,
        )
        if present.returncode != 0:
            result["reasons"].append(
                f"remote head {ancestor} is not present locally, so it cannot be "
                "proven an ancestor of the commit being published"
            )
            return result
        res = subprocess.run(
            ["git", "-C", path, "merge-base", "--is-ancestor", ancestor, descendant],
            capture_output=True, text=True, check=False,
        )
    except OSError as exc:
        result["reasons"].append(f"ancestry probe could not run: {exc}")
        return result
    result["probe_ok"] = res.returncode in (0, 1)
    result["is_ancestor"] = res.returncode == 0
    return result


def assess_unpublished_commit_publication(
    existing_lock,
    *,
    issue_number: int,
    branch_name: str,
    worktree_path: str,
    expected_head: str,
    remote: str,
    org: str,
    repo: str,
    identity: str | None,
    profile: str | None,
    worktree_state,
    worktree_registered: bool | None = None,
    remote_probe=None,
    ancestry=None,
    competing_open_prs=(),
    expected_file_hashes=None,
    observed_file_hashes=None,
) -> dict:
    """Decide whether an unpublished local commit may be published (#812 AC20).

    Pure predicate. Every input is either a caller-declared expectation that
    must be *matched* against observation, or a server-side observation. No
    caller-supplied boolean is accepted as proof of ownership, liveness, or
    eligibility: ``existing_lock`` comes from the durable lock file and the
    git/PR state is observed by the server.

    The single mutating disposition it can return is "publish this exact commit
    to this exact branch". It never sanctions renewal, reclamation, force
    updates, history rewriting, or publication of uncommitted content.
    """
    reasons: list[str] = []
    branch = _text(branch_name)
    head = _text(expected_head).lower()
    workspace = _realpath(worktree_path)
    state = worktree_state if isinstance(worktree_state, dict) else {}
    lock = existing_lock if isinstance(existing_lock, dict) else None

    evidence: dict = {
        "issue_number": issue_number,
        "branch_name": branch or None,
        "worktree_path": workspace,
        "expected_head": head or None,
        "remote": _text(remote) or None,
        "org": _text(org) or None,
        "repo": _text(repo) or None,
        "identity": _text(identity) or None,
        "profile": _text(profile) or None,
        "lock_record_present": lock is not None,
        "recorded_claimant": None,
        "recorded_branch": None,
        "recorded_worktree": None,
        "lock_generation": None,
        "local_head_sha": _text(state.get("head_sha")) or None,
        "current_branch": _text(state.get("current_branch")) or None,
        "dirty_tracked_files": [],
        "untracked_files": [],
        "worktree_registered": worktree_registered,
        "remote_branch_exists": None,
        "remote_head_sha": None,
        "fast_forward_from_remote": None,
        "competing_open_prs": [],
        "file_hashes_verified": None,
        "hash_mismatches": [],
        # Recorded explicitly so no reader mistakes silence for a liveness
        # claim, and so the audit shows which records were left alone (AC23/AC24).
        "owner_pid_liveness_consulted": False,
        "workflow_lease_touched": False,
        "issue_lock_record_mutated": False,
    }

    # ── declared shape ────────────────────────────────────────────────────
    if not branch:
        reasons.append("branch name not declared; fail closed")
    if not head:
        reasons.append(
            "expected_head not declared; publication must name the exact commit"
        )
    elif not _SHA_RE.match(head):
        reasons.append(
            f"expected_head '{head}' is not a full 40-character commit SHA; "
            "abbreviated or symbolic revisions are refused"
        )
    if not workspace:
        reasons.append("worktree path not declared; fail closed")

    if branch:
        match = ISSUE_BRANCH_RE.match(branch)
        if not match:
            reasons.append(
                f"branch '{branch}' is not an issue-linked implementation branch "
                "((fix|feat|docs|chore)/issue-<number>-<description>); fail closed"
            )
        elif int(match.group(2)) != int(issue_number):
            reasons.append(
                f"branch '{branch}' does not carry issue number {issue_number}; "
                "fail closed"
            )
        if is_stable_ref(branch):
            reasons.append(
                f"refusing to publish stable branch '{branch}'; this operation "
                "publishes issue branches only"
            )

    # ── ownership: durable issue-lock record only (AC8, AC20, AC24) ───────
    if lock is None:
        reasons.append(
            "no durable issue-lock record for this issue; publication requires an "
            "existing recorded claim naming the caller, so this operation cannot "
            "be used to bypass the author lock"
        )
    else:
        lease = lock.get("work_lease")
        lease = lease if isinstance(lease, dict) else {}
        claimant = lease.get("claimant")
        claimant = claimant if isinstance(claimant, dict) else {}
        recorded_user = _text(claimant.get("username"))
        recorded_profile = _text(claimant.get("profile"))
        recorded_branch = _text(lock.get("branch_name")) or _text(lease.get("branch"))
        recorded_worktree = _realpath(
            _text(lock.get("worktree_path")) or _text(lease.get("worktree_path"))
        )
        evidence["recorded_claimant"] = {
            "username": recorded_user or None,
            "profile": recorded_profile or None,
        }
        evidence["recorded_branch"] = recorded_branch or None
        evidence["recorded_worktree"] = recorded_worktree
        try:
            evidence["lock_generation"] = int(lock.get("lock_generation") or 0)
        except (TypeError, ValueError):
            evidence["lock_generation"] = 0

        try:
            recorded_issue = int(lock.get("issue_number") or 0)
        except (TypeError, ValueError):
            recorded_issue = 0
        if recorded_issue != int(issue_number):
            reasons.append(
                f"durable lock records issue {lock.get('issue_number')}, not "
                f"{issue_number}; ambiguous ownership, fail closed"
            )
        for field, declared in (
            ("remote", _text(remote)),
            ("org", _text(org)),
            ("repo", _text(repo)),
        ):
            recorded = _text(lock.get(field))
            if recorded and declared and recorded != declared:
                reasons.append(
                    f"durable lock records {field} '{recorded}' but the request "
                    f"declares '{declared}'; repository mismatch, fail closed"
                )
        if recorded_branch and branch and recorded_branch != branch:
            reasons.append(
                f"durable lock records branch '{recorded_branch}' but the request "
                f"declares '{branch}'; fail closed"
            )
        if recorded_worktree and workspace and recorded_worktree != workspace:
            reasons.append(
                f"durable lock records worktree '{recorded_worktree}' but the "
                f"request declares '{workspace}'; fail closed"
            )
        if not recorded_user or not recorded_profile:
            reasons.append(
                "durable lock does not record a claimant username and profile; "
                "ownership cannot be proven, fail closed"
            )
        else:
            if recorded_user != _text(identity):
                reasons.append(
                    f"durable lock claimant '{recorded_user}' is not the acting "
                    f"identity '{_text(identity) or '(unknown)'}'; foreign claim, "
                    "fail closed"
                )
            if recorded_profile != _text(profile):
                reasons.append(
                    f"durable lock claimant profile '{recorded_profile}' is not "
                    f"the active profile '{_text(profile) or '(unknown)'}'; "
                    "fail closed"
                )

    # ── worktree: registered, on-branch, clean, at the expected commit ────
    if worktree_registered is False:
        reasons.append(
            f"worktree '{workspace}' is not listed in git worktree list; #713 "
            "requires a genuinely registered worktree, fail closed"
        )

    current_branch = _text(state.get("current_branch"))
    if not current_branch:
        reasons.append("worktree branch could not be observed; fail closed")
    elif branch and current_branch != branch:
        reasons.append(
            f"worktree is on branch '{current_branch}', not '{branch}'; fail closed"
        )

    porcelain = state.get("porcelain_status") or ""
    dirty_tracked = parse_dirty_tracked_files(porcelain)
    untracked = parse_untracked_files(porcelain)
    evidence["dirty_tracked_files"] = dirty_tracked
    evidence["untracked_files"] = untracked
    if dirty_tracked:
        reasons.append(
            "worktree has dirty tracked files, so the commit is not the whole of "
            f"the work: {', '.join(dirty_tracked)}. This operation publishes an "
            "existing clean commit only; uncommitted content is out of scope"
        )
    if untracked:
        reasons.append(
            "worktree has untracked files that the commit does not carry: "
            f"{', '.join(untracked)}. Publishing would silently leave them "
            "behind; fail closed"
        )

    local_head = _text(state.get("head_sha")).lower()
    if not local_head:
        reasons.append("local HEAD could not be observed; fail closed")
    elif head and local_head != head:
        reasons.append(
            f"worktree HEAD is {local_head} but the request declares {head}; the "
            "local commit changed since it was recorded, fail closed"
        )

    # ── remote state ──────────────────────────────────────────────────────
    probe = remote_probe if isinstance(remote_probe, dict) else {}
    already_published = False
    if not probe.get("probe_ok"):
        reasons.append(
            "remote branch head could not be observed; publication must not "
            "proceed against an unknown remote state, fail closed"
        )
        reasons.extend(probe.get("reasons") or [])
    else:
        remote_exists = bool(probe.get("remote_branch_exists"))
        remote_head = _text(probe.get("remote_head_sha")).lower() or None
        evidence["remote_branch_exists"] = remote_exists
        evidence["remote_head_sha"] = remote_head
        if remote_exists and remote_head and head:
            if remote_head == head:
                already_published = True
                evidence["fast_forward_from_remote"] = True
            else:
                anc = ancestry if isinstance(ancestry, dict) else {}
                is_anc = bool(anc.get("probe_ok")) and bool(anc.get("is_ancestor"))
                evidence["fast_forward_from_remote"] = is_anc
                if not is_anc:
                    reasons.append(
                        f"remote branch '{branch}' already exists at {remote_head}, "
                        f"which is not an ancestor of {head}; publishing would "
                        "discard or rewrite published history, fail closed"
                    )
                    reasons.extend(anc.get("reasons") or [])
        elif remote_exists and not remote_head:
            reasons.append(
                f"remote branch '{branch}' exists but its head could not be read; "
                "fail closed"
            )

    # ── competing claims ──────────────────────────────────────────────────
    competing = [p for p in (competing_open_prs or ()) if p]
    evidence["competing_open_prs"] = list(competing)
    if competing:
        reasons.append(
            f"open pull request(s) {competing} already claim issue {issue_number} "
            "or this branch; ambiguous ownership, fail closed"
        )

    # ── content verification before publication ───────────────────────────
    if expected_file_hashes:
        observed = (
            observed_file_hashes if isinstance(observed_file_hashes, dict) else {}
        )
        mismatches: list[str] = []
        for path, expected_digest in dict(expected_file_hashes).items():
            actual = observed.get(path)
            if actual is None:
                mismatches.append(f"{path}: missing or unreadable in the worktree")
            elif _text(actual).lower() != _text(expected_digest).lower():
                mismatches.append(
                    f"{path}: expected {expected_digest}, observed {actual}"
                )
        evidence["hash_mismatches"] = mismatches
        evidence["file_hashes_verified"] = not mismatches
        if mismatches:
            reasons.append(
                "declared content hashes do not match the worktree: "
                + "; ".join(mismatches)
                + ". Refusing to publish content that is not what was recorded"
            )

    if reasons:
        return {
            "outcome": REFUSED,
            "publish_sanctioned": False,
            "already_published": False,
            "reasons": reasons,
            "evidence": evidence,
        }
    return {
        "outcome": ALREADY_PUBLISHED if already_published else PUBLISH_SANCTIONED,
        # Idempotent retry: a remote head that already equals the assessed commit
        # needs no second push, so the caller verifies instead of acting.
        "publish_sanctioned": not already_published,
        "already_published": already_published,
        "reasons": [],
        "evidence": evidence,
    }


def publish_commit_to_remote_branch(
    *,
    worktree_path: str,
    remote_name: str,
    branch_name: str,
    expected_head: str,
) -> dict:
    """Send exactly *expected_head* to ``refs/heads/<branch_name>``.

    The refspec names the commit SHA explicitly rather than ``HEAD`` or the
    local branch, so what lands is the commit that was assessed and nothing
    else. No force, no lease, no ``+`` prefix: a non-fast-forward is rejected by
    git itself, the last of several independent guards against overwriting
    published history.
    """
    path = _text(worktree_path)
    remote = _text(remote_name)
    branch = _text(branch_name)
    head = _text(expected_head)
    result: dict = {
        "success": False,
        "pushed_ref": f"refs/heads/{branch}" if branch else None,
        "pushed_sha": head or None,
        "stderr": None,
        "reasons": [],
    }
    if not (path and remote and branch and head):
        result["reasons"].append(
            "publication requires a worktree path, remote, branch, and commit SHA"
        )
        return result

    refspec = f"{head}:refs/heads/{branch}"
    try:
        res = subprocess.run(
            ["git", "-C", path, "push", remote, refspec],
            capture_output=True,
            text=True,
            check=False,
        )
    except OSError as exc:
        result["reasons"].append(f"publication could not run: {exc}")
        return result

    if res.returncode != 0:
        # Redact before surfacing: failures can echo credentialed remote URLs.
        result["stderr"] = redact_command(res.stderr or "")
        result["reasons"].append(
            f"publication of {head} to '{branch}' on remote '{remote}' failed"
        )
        return result

    result["success"] = True
    return result


def verify_published_head(
    *, worktree_path: str, remote_name: str, branch_name: str, expected_head: str
) -> dict:
    """Read-after-write: confirm the remote head equals *expected_head* (AC20)."""
    probe = read_remote_branch_head(worktree_path, remote_name, branch_name)
    head = _text(expected_head).lower()
    observed = _text(probe.get("remote_head_sha")).lower() or None
    verified = bool(head) and bool(probe.get("probe_ok")) and observed == head
    reasons: list[str] = list(probe.get("reasons") or [])
    if probe.get("probe_ok") and not verified:
        reasons.append(
            "read-after-write verification failed: remote head is "
            f"{observed or '(absent)'}, expected {head}"
        )
    return {
        "verified": verified,
        "remote_head_sha": observed,
        "expected_head": head or None,
        "reasons": reasons,
    }
