"""Publication of an unpublished local commit (#812 AC20).

Entry point B of #812: a registered worktree, clean, on its issue branch,
holding a local commit that has never been published. Exact-owner lease renewal
refuses such a claim for want of an observable remote head, and every existing
publication path is lock-derived, so the two predicates close a cycle around
work that is otherwise complete.

These tests exercise the disposition through its *evidence*, never through any
particular issue number: every case uses an arbitrary issue number against a
synthetic repository, and the same assertions hold for any other. Nothing here
reads, writes, or references the live protected worktree named in #812 AC17 —
that content is preserved evidence for the duration of this work, so the
fixtures below build their own repositories from scratch.

The remote is a local bare repository, so publication and read-after-write
verification are genuinely executed rather than mocked.
"""

from __future__ import annotations

import os
import subprocess
import sys
import tempfile
import unittest
from datetime import datetime, timedelta, timezone
from unittest.mock import patch

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

import branch_publish  # noqa: E402
import issue_lock_provenance  # noqa: E402
import issue_lock_renewal  # noqa: E402
import issue_lock_store  # noqa: E402
import mcp_server  # noqa: E402
from mutation_profile_fixture import shared_mutation_env  # noqa: E402

ISSUE = 9812
BRANCH = f"feat/issue-{ISSUE}-publish-fixture"
IDENTITY = "example-user"
PROFILE = "test-author-prgs"
ORG = "Scaled-Tech-Consulting"
REPO = "Gitea-Tools"
GIT_REMOTE = "prgs"


def _ts(hours: int) -> str:
    return (
        (datetime.now(timezone.utc) + timedelta(hours=hours))
        .isoformat()
        .replace("+00:00", "Z")
    )


class _PublishBase(unittest.TestCase):
    """Real git repo + real bare remote + durable lock naming the caller.

    The recorded owner pid is deliberately **this live process**. That mirrors
    the production shape #812 documents, where the pid belongs to a long-running
    MCP daemon rather than to a dead author client, and it proves publication
    never depends on a dead process (#812 AC24).
    """

    def setUp(self):
        self.lock_dir = tempfile.TemporaryDirectory()
        self.addCleanup(self.lock_dir.cleanup)
        self.origin = tempfile.mkdtemp(prefix="issue812-origin-")
        self.repo = tempfile.mkdtemp(prefix="issue812-work-")
        for path in (self.origin, self.repo):
            self.addCleanup(
                lambda p=path: subprocess.run(["rm", "-rf", p], check=False)
            )
        self._init_repos()
        self.remotes = patch.dict(
            mcp_server.REMOTES,
            {"prgs": {"host": "gitea.prgs.cc", "org": ORG, "repo": REPO}},
        )
        self.remotes.start()
        self.addCleanup(patch.stopall)
        mcp_server._IDENTITY_CACHE.clear()

    # ── fixture construction ─────────────────────────────────────────────
    def _git(self, *args, cwd=None):
        return subprocess.run(
            ["git", "-C", cwd or self.repo, *args],
            capture_output=True,
            text=True,
            check=True,
        )

    def _init_repos(self):
        subprocess.run(
            ["git", "init", "-q", "--bare", "-b", "master", self.origin], check=True
        )
        self._git("init", "-q", "-b", "master")
        self._git("config", "user.email", "test@example.com")
        self._git("config", "user.name", "Test")
        self._git("remote", "add", GIT_REMOTE, self.origin)

        with open(os.path.join(self.repo, "seed.txt"), "w") as fh:
            fh.write("seed\n")
        self._git("add", "seed.txt")
        self._git("commit", "-q", "-m", "seed")
        self.base_sha = self._git("rev-parse", "HEAD").stdout.strip()
        self._git("push", "-q", GIT_REMOTE, "master")

        self._git("checkout", "-q", "-b", BRANCH)
        with open(os.path.join(self.repo, "work.txt"), "w") as fh:
            fh.write("unpublished implementation\n")
        self._git("add", "work.txt")
        self._git("commit", "-q", "-m", "unpublished implementation")
        self.head_sha = self._git("rev-parse", "HEAD").stdout.strip()
        self.worktree = os.path.realpath(self.repo)

    def lock_path(self):
        return issue_lock_store.lock_file_path(
            remote="prgs", org=ORG, repo=REPO, issue_number=ISSUE,
            lock_dir=self.lock_dir.name,
        )

    def write_lock(self, **overrides):
        path = self.lock_path()
        claimant = overrides.pop(
            "claimant", {"username": IDENTITY, "profile": PROFILE}
        )
        pid = overrides.pop("session_pid", os.getpid())
        lease = {
            "operation_type": issue_lock_store.AUTHOR_ISSUE_WORK_LEASE,
            "issue_number": ISSUE,
            "pr_number": None,
            "branch": overrides.get("branch_name", BRANCH),
            "worktree_path": overrides.get("worktree_path", self.worktree),
            "claimant": claimant,
            "created_at": _ts(-2),
            "last_heartbeat_at": _ts(-2),
            # Expired: entry point B's lease has lapsed, which is precisely why
            # renewal — and therefore a published head — is needed.
            "expires_at": _ts(-1),
        }
        lease.update(overrides.pop("work_lease", {}))
        data = {
            "issue_number": ISSUE,
            "branch_name": BRANCH,
            "remote": "prgs",
            "org": ORG,
            "repo": REPO,
            "worktree_path": self.worktree,
            "session_pid": pid,
            "pid": pid,
            "lock_generation": 1,
            "work_lease": lease,
            "lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
                tool="gitea_lock_issue", claimant=claimant
            ),
        }
        data.update(overrides)
        data["lock_file_path"] = path
        issue_lock_store.save_lock_file(path, data)
        return path

    def _tool_env(self):
        env = shared_mutation_env(
            PROFILE, include_example_repo=True,
            GITEA_ISSUE_LOCK_DIR=self.lock_dir.name,
        )
        env["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
        # These tests repoint PROJECT_ROOT at a synthetic repository so the
        # registered-worktree proof runs for real. Pin the parity gate to the
        # server's own startup head so that repointing does not read as a stale
        # daemon; the gate itself stays live and enforced.
        startup_head = mcp_server._STARTUP_PARITY.get("startup_head") or ""
        env["GITEA_TEST_CURRENT_HEAD"] = startup_head
        env["GITEA_TEST_LIVE_REMOTE_HEAD"] = startup_head
        return env

    # ── tool driver ──────────────────────────────────────────────────────
    def run_publish(self, *, open_prs=None, expected_head=None, **kwargs):
        """Drive the public publication tool against the synthetic fixture."""
        env = self._tool_env()
        with patch(
            "mcp_server._list_open_pulls", return_value=list(open_prs or [])
        ), patch(
            "mcp_server._auth", return_value="token x"
        ), patch(
            "mcp_server.get_auth_header", return_value="token x"
        ), patch(
            "mcp_server._work_lease_claimant",
            return_value={"username": IDENTITY, "profile": PROFILE},
        ), patch.object(
            mcp_server, "PROJECT_ROOT", self.repo
        ), patch.dict(os.environ, env, clear=True):
            os.environ["GITEA_ISSUE_LOCK_DIR"] = self.lock_dir.name
            return mcp_server.gitea_publish_unpublished_issue_branch(
                issue_number=kwargs.pop("issue_number", ISSUE),
                branch_name=kwargs.pop("branch_name", BRANCH),
                worktree_path=kwargs.pop("worktree_path", self.worktree),
                expected_head=expected_head or self.head_sha,
                remote="prgs",
                git_remote_name=kwargs.pop("git_remote_name", GIT_REMOTE),
                **kwargs,
            )

    def remote_head(self, branch=BRANCH):
        res = subprocess.run(
            ["git", "-C", self.origin, "rev-parse", "--verify", "--quiet", branch],
            capture_output=True, text=True, check=False,
        )
        return (res.stdout or "").strip() or None


class TestSuccessfulPublication(_PublishBase):
    """AC20 — the branch becomes observable and is verified after the write."""

    def test_publishes_clean_unpublished_commit(self):
        self.write_lock()
        self.assertIsNone(self.remote_head(), "fixture must start unpublished")

        result = self.run_publish()

        self.assertTrue(result["success"], result.get("reasons"))
        self.assertTrue(result["performed"])
        self.assertTrue(result["published"])
        self.assertTrue(result["verified"], "read-after-write must be proven")
        self.assertEqual(result["remote_head_sha"], self.head_sha)
        self.assertEqual(self.remote_head(), self.head_sha)

    def test_publication_does_not_rewrite_the_commit(self):
        self.write_lock()
        self.run_publish()
        # The published object is the same commit, not a copy or a rewrite.
        self.assertEqual(self.remote_head(), self.head_sha)
        self.assertEqual(
            self._git("rev-parse", "HEAD").stdout.strip(), self.head_sha
        )

    def test_exact_next_action_names_the_lock_call(self):
        self.write_lock()
        result = self.run_publish()
        self.assertIn("gitea_lock_issue", result["exact_next_action"])


class TestFailsClosed(_PublishBase):
    """AC20/AC9 — each refusal reason, exercised independently."""

    def test_changed_local_head_refuses(self):
        self.write_lock()
        stale = self.base_sha  # a real commit, but not the declared head
        result = self.run_publish(expected_head=stale)
        self.assertFalse(result["success"])
        self.assertTrue(
            any("local commit changed" in r for r in result["reasons"]),
            result["reasons"],
        )
        self.assertIsNone(self.remote_head(), "refusal must not publish")

    def test_abbreviated_sha_refuses(self):
        self.write_lock()
        result = self.run_publish(expected_head=self.head_sha[:8])
        self.assertFalse(result["success"])
        self.assertTrue(
            any("40-character" in r for r in result["reasons"]), result["reasons"]
        )

    def test_dirty_tracked_worktree_refuses(self):
        self.write_lock()
        with open(os.path.join(self.repo, "work.txt"), "a") as fh:
            fh.write("uncommitted edit\n")

        result = self.run_publish()

        self.assertFalse(result["success"])
        self.assertTrue(
            any("dirty tracked files" in r for r in result["reasons"]),
            result["reasons"],
        )
        self.assertIn("work.txt", result["evidence"]["dirty_tracked_files"])
        self.assertIsNone(self.remote_head())

    def test_untracked_file_refuses(self):
        self.write_lock()
        with open(os.path.join(self.repo, "stray.txt"), "w") as fh:
            fh.write("not committed\n")

        result = self.run_publish()

        self.assertFalse(result["success"])
        self.assertTrue(
            any("untracked files" in r for r in result["reasons"]), result["reasons"]
        )
        self.assertIn("stray.txt", result["evidence"]["untracked_files"])
        self.assertIsNone(self.remote_head())

    def test_unexpected_remote_head_refuses(self):
        """A remote head that is not an ancestor must never be overwritten."""
        self.write_lock()
        # Publish a divergent commit to the branch from a separate line.
        self._git("checkout", "-q", "-b", "divergent", self.base_sha)
        with open(os.path.join(self.repo, "other.txt"), "w") as fh:
            fh.write("someone else's work\n")
        self._git("add", "other.txt")
        self._git("commit", "-q", "-m", "divergent")
        divergent = self._git("rev-parse", "HEAD").stdout.strip()
        self._git("push", "-q", GIT_REMOTE, f"{divergent}:refs/heads/{BRANCH}")
        self._git("checkout", "-q", BRANCH)

        result = self.run_publish()

        self.assertFalse(result["success"])
        self.assertTrue(
            any("not an ancestor" in r for r in result["reasons"]), result["reasons"]
        )
        self.assertEqual(
            self.remote_head(), divergent, "the other head must survive intact"
        )

    def test_fast_forward_remote_head_is_allowed(self):
        """An ancestor head is an honest fast-forward, not a conflict."""
        self.write_lock()
        self._git("push", "-q", GIT_REMOTE, f"{self.base_sha}:refs/heads/{BRANCH}")

        result = self.run_publish()

        self.assertTrue(result["success"], result.get("reasons"))
        self.assertTrue(result["evidence"]["fast_forward_from_remote"])
        self.assertEqual(self.remote_head(), self.head_sha)

    def test_content_hash_mismatch_refuses(self):
        self.write_lock()
        wrong = {"work.txt": "0" * 64}

        result = self.run_publish(expected_file_hashes=wrong)

        self.assertFalse(result["success"])
        self.assertTrue(
            any("declared content hashes" in r for r in result["reasons"]),
            result["reasons"],
        )
        self.assertFalse(result["evidence"]["file_hashes_verified"])
        self.assertIsNone(self.remote_head())

    def test_matching_content_hashes_publish(self):
        self.write_lock()
        digests = branch_publish.hash_worktree_files(self.worktree, ["work.txt"])

        result = self.run_publish(expected_file_hashes=digests)

        self.assertTrue(result["success"], result.get("reasons"))
        self.assertTrue(result["evidence"]["file_hashes_verified"])

    def test_missing_declared_file_refuses(self):
        self.write_lock()
        result = self.run_publish(expected_file_hashes={"absent.txt": "0" * 64})
        self.assertFalse(result["success"])
        self.assertTrue(
            any("missing or unreadable" in r for r in result["reasons"]),
            result["reasons"],
        )

    def test_foreign_claimant_refuses(self):
        """Ownership comes from the durable record, not from the caller."""
        self.write_lock(claimant={"username": "someone-else", "profile": PROFILE})

        result = self.run_publish()

        self.assertFalse(result["success"])
        self.assertTrue(
            any("foreign claim" in r for r in result["reasons"]), result["reasons"]
        )
        self.assertIsNone(self.remote_head())

    def test_foreign_profile_refuses(self):
        self.write_lock(
            claimant={"username": IDENTITY, "profile": "test-reviewer-prgs"}
        )
        result = self.run_publish()
        self.assertFalse(result["success"])
        self.assertTrue(
            any("claimant profile" in r for r in result["reasons"]), result["reasons"]
        )

    def test_absent_lock_record_refuses(self):
        """No recorded claim means this cannot be used to bypass the lock."""
        result = self.run_publish()  # no write_lock()

        self.assertFalse(result["success"])
        self.assertTrue(
            any("no durable issue-lock record" in r for r in result["reasons"]),
            result["reasons"],
        )
        self.assertIsNone(self.remote_head())

    def test_branch_mismatch_against_lock_refuses(self):
        self.write_lock(branch_name=f"feat/issue-{ISSUE}-different")
        result = self.run_publish()
        self.assertFalse(result["success"])
        self.assertTrue(
            any("records branch" in r for r in result["reasons"]), result["reasons"]
        )

    def test_worktree_mismatch_against_lock_refuses(self):
        self.write_lock(worktree_path="/tmp/some/other/worktree")
        result = self.run_publish()
        self.assertFalse(result["success"])
        self.assertTrue(
            any("records worktree" in r for r in result["reasons"]), result["reasons"]
        )

    def test_competing_open_pr_on_another_branch_refuses(self):
        self.write_lock()
        competing = [{"number": 4242, "head": {"ref": f"fix/issue-{ISSUE}-rival"}}]

        result = self.run_publish(open_prs=competing)

        self.assertFalse(result["success"])
        self.assertTrue(
            any("already claim issue" in r for r in result["reasons"]),
            result["reasons"],
        )
        self.assertIsNone(self.remote_head())

    def test_open_pr_on_the_same_branch_is_not_competing(self):
        """This branch's own PR is not a rival claim against itself."""
        self.write_lock()
        own = [{"number": 77, "head": {"ref": BRANCH}}]

        result = self.run_publish(open_prs=own)

        self.assertTrue(result["success"], result.get("reasons"))


class TestGuardStrictnessPreserved(_PublishBase):
    """AC15 — publication is an operation, never a weakening of the guards."""

    def test_non_issue_branch_refuses(self):
        self._git("checkout", "-q", "-b", "scratch/not-issue-linked")
        self.write_lock(branch_name="scratch/not-issue-linked")

        result = self.run_publish(branch_name="scratch/not-issue-linked")

        self.assertFalse(result["success"])
        self.assertTrue(
            any("issue-linked" in r for r in result["reasons"]), result["reasons"]
        )

    def test_stable_branch_refuses(self):
        self.write_lock(branch_name="master")
        result = self.run_publish(branch_name="master")
        self.assertFalse(result["success"])
        self.assertTrue(
            any("issue-linked" in r or "stable branch" in r for r in result["reasons"]),
            result["reasons"],
        )

    def test_branch_number_must_match_the_issue(self):
        other = "feat/issue-7777-mismatched"
        self._git("checkout", "-q", "-b", other)
        self.write_lock(branch_name=other)
        result = self.run_publish(branch_name=other)
        self.assertFalse(result["success"])
        self.assertTrue(
            any("does not carry issue number" in r for r in result["reasons"]),
            result["reasons"],
        )

    def test_unregistered_worktree_refuses(self):
        """#713 — an improvised directory is not a registered worktree."""
        path = self.write_lock()
        assessment = branch_publish.assess_unpublished_commit_publication(
            issue_lock_store.read_lock_file(path),
            issue_number=ISSUE, branch_name=BRANCH, worktree_path=self.worktree,
            expected_head=self.head_sha, remote="prgs", org=ORG, repo=REPO,
            identity=IDENTITY, profile=PROFILE,
            worktree_state={
                "current_branch": BRANCH, "porcelain_status": "",
                "head_sha": self.head_sha,
            },
            worktree_registered=False,
            remote_probe={"probe_ok": True, "remote_branch_exists": False},
        )
        self.assertEqual(assessment["outcome"], branch_publish.REFUSED)
        self.assertTrue(
            any("not listed in git worktree list" in r
                for r in assessment["reasons"]),
            assessment["reasons"],
        )

    def test_unobservable_remote_refuses(self):
        """An unknown remote state must not be mistaken for an absent branch."""
        self.write_lock()
        result = self.run_publish(git_remote_name="no-such-remote")
        self.assertFalse(result["success"])
        self.assertTrue(
            any("could not be observed" in r for r in result["reasons"]),
            result["reasons"],
        )


class TestRecordSeparation(_PublishBase):
    """AC23 — the durable issue lock and the workflow lease are distinct."""

    def test_publication_leaves_the_issue_lock_byte_identical(self):
        path = self.write_lock()
        with open(path, "rb") as fh:
            before = fh.read()

        result = self.run_publish()

        self.assertTrue(result["success"], result.get("reasons"))
        with open(path, "rb") as fh:
            after = fh.read()
        self.assertEqual(before, after, "publication must not mutate the lock record")
        self.assertFalse(result["issue_lock_record_mutated"])
        self.assertFalse(result["workflow_lease_touched"])

    def test_refusal_also_reports_untouched_records(self):
        result = self.run_publish()  # refuses: no lock record
        self.assertFalse(result["issue_lock_record_mutated"])
        self.assertFalse(result["workflow_lease_touched"])

    def test_lock_generation_is_not_advanced(self):
        path = self.write_lock()
        self.run_publish()
        lock = issue_lock_store.read_lock_file(path)
        self.assertEqual(lock["lock_generation"], 1)


class TestTruthfulProcessEvidence(_PublishBase):
    """AC24 — a live daemon pid is never represented as a dead process."""

    def test_live_recorded_pid_does_not_block_publication(self):
        # The recorded pid is this live process, standing in for the live MCP
        # daemon. Reclaim would refuse here; publication legitimately does not.
        path = self.write_lock(session_pid=os.getpid())
        lock = issue_lock_store.read_lock_file(path)
        self.assertEqual(lock["pid"], os.getpid())

        result = self.run_publish()

        self.assertTrue(result["success"], result.get("reasons"))
        self.assertEqual(self.remote_head(), self.head_sha)

    def test_liveness_is_not_consulted_as_evidence(self):
        self.write_lock(session_pid=os.getpid())
        result = self.run_publish()
        self.assertFalse(result["evidence"]["owner_pid_liveness_consulted"])

    def test_reclaim_still_refuses_for_the_same_live_pid(self):
        """Publication does not soften the reclaim predicate it routes around."""
        path = self.write_lock(session_pid=os.getpid())
        lock = issue_lock_store.read_lock_file(path)
        reclaim = issue_lock_store.assess_expired_lock_reclaim(lock)
        self.assertFalse(reclaim["reclaim_allowed"])


class TestIdempotentRetry(_PublishBase):
    """AC20 — retry is safe and read-after-write is proven every time."""

    def test_second_publication_reports_already_published(self):
        self.write_lock()
        first = self.run_publish()
        self.assertTrue(first["performed"])

        second = self.run_publish()

        self.assertTrue(second["success"], second.get("reasons"))
        self.assertFalse(second["performed"], "no second push is needed")
        self.assertTrue(second["published"])
        self.assertTrue(second["verified"])
        self.assertEqual(second["outcome"], branch_publish.ALREADY_PUBLISHED)
        self.assertEqual(self.remote_head(), self.head_sha)


class TestDryRun(_PublishBase):
    """AC12 — dry run reports the decision and mutates nothing."""

    def test_dry_run_reports_intent_without_publishing(self):
        self.write_lock()

        result = self.run_publish(dry_run=True)

        self.assertTrue(result["success"])
        self.assertTrue(result["dry_run"])
        self.assertTrue(result["would_publish"])
        self.assertFalse(result["performed"])
        self.assertIsNone(self.remote_head(), "dry run must not publish")

    def test_dry_run_and_apply_agree_on_a_refusal(self):
        """AC11 — the reported decision does not depend on which mode ran."""
        self.write_lock(claimant={"username": "someone-else", "profile": PROFILE})

        dry = self.run_publish(dry_run=True)
        applied = self.run_publish()

        self.assertFalse(dry["success"])
        self.assertFalse(applied["success"])
        self.assertEqual(dry["reasons"], applied["reasons"])


class TestRenewalUnblocked(_PublishBase):
    """AC20/AC21 — renewal is permitted only after verified publication."""

    def _renewal(self, remote_head):
        return issue_lock_renewal.assess_exact_owner_lease_renewal(
            issue_lock_store.read_lock_file(self.lock_path()),
            issue_number=ISSUE, branch_name=BRANCH, worktree_path=self.worktree,
            remote="prgs", org=ORG, repo=REPO,
            identity=IDENTITY, profile=PROFILE,
            current_branch=BRANCH, porcelain_status="", worktree_exists=True,
            head_sha=self.head_sha, remote_head_sha=remote_head,
        )

    def test_renewal_refuses_before_publication(self):
        self.write_lock()
        decision = self._renewal(None)
        self.assertFalse(decision["renewal_sanctioned"])
        self.assertTrue(
            any("unpublished branch" in r for r in decision["reasons"]),
            decision["reasons"],
        )

    def test_renewal_is_sanctioned_after_publication(self):
        self.write_lock()
        result = self.run_publish()
        self.assertTrue(result["verified"], result.get("reasons"))

        decision = self._renewal(self.remote_head())

        self.assertTrue(decision["renewal_sanctioned"], decision["reasons"])


class TestProtectedAssetUntouched(unittest.TestCase):
    """AC17 — no test or fixture may reference the protected worktree."""

    def test_no_reference_to_the_protected_worktree(self):
        here = os.path.dirname(os.path.abspath(__file__))
        root = os.path.dirname(here)
        needle = "issue-635-project-registry" + "-api"
        for path in (
            os.path.join(here, "test_issue_812_publish_unpublished_commit.py"),
            os.path.join(root, "branch_publish.py"),
        ):
            with open(path, "r", encoding="utf-8") as fh:
                body = fh.read()
            self.assertNotIn(needle, body)


if __name__ == "__main__":  # pragma: no cover
    unittest.main()
