"""Complete PROJECT_ROOT elimination in cross-repository MCP operations (#741).

#706 introduced the immutable ``canonical_repository_root``; #739/#740 routed
three consumption paths through it. This module covers the paths #740 left
behind, whose shape is uniform: the *filesystem* guards were migrated to the
canonical root, but *repository identity* still bottomed out in
``_local_git_remote_url``'s hardcoded ``cwd=PROJECT_ROOT`` — always the
Gitea-Tools installation checkout.

The consequences asserted here:

* **Identity inversion.** ``_resolve``, the #530 remote/repo guard and the
  anti-stomp org/repo fill all derived the *target* repository from the
  *install* checkout, so a cross-repository namespace resolved Gitea-Tools
  coordinates while its branch/parity facts came from the target repo.
* **Guard disagreement.** ``_verify_role_mutation_workspace`` omitted
  ``configured_canonical_root``, so the #274 branches-only / worktree-membership
  guards validated ``Gitea-Tools/branches/`` rather than the bound target.
* **Explicit-coordinate override.** Both-explicit ``org``/``repo`` short-circuit
  ``assess_remote_repo_match``, so caller coordinates bypassed validation
  entirely rather than merely *confirming* the binding.
* **Configuration fail-open.** ``_flatten_identity`` silently dropped
  ``canonical_repository_root``, so a v2-``environments`` namespace fell back to
  the install root; the v1 path never validated the field at all.

A role-by-operation matrix drives author, reviewer, merger and reconciler
against: correct cross-repository target, wrong repository, wrong worktree,
explicit matching coordinates, explicit mismatched coordinates, missing
canonical root, invalid canonical root, immutable binding after first bind, and
request-override attempts.

Real git repositories are used throughout. No network calls are made, no branch
is deleted, and no merge is performed.
"""

from __future__ import annotations

import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

import canonical_repository_root as crr  # noqa: E402
import gitea_config  # noqa: E402
import gitea_mcp_server as srv  # noqa: E402
import mcp_server  # noqa: E402
import session_context_binding as session_ctx  # noqa: E402

INSTALL_ORG = "Scaled-Tech-Consulting"
INSTALL_REPO = "Gitea-Tools"
INSTALL_SLUG = f"{INSTALL_ORG}/{INSTALL_REPO}"
INSTALL_URL = f"https://gitea.prgs.cc/{INSTALL_SLUG}.git"

TARGET_ORG = "Scaled-Tech-Consulting"
TARGET_REPO = "mcp-control-plane"
TARGET_SLUG = f"{TARGET_ORG}/{TARGET_REPO}"
TARGET_URL = f"https://gitea.prgs.cc/{TARGET_SLUG}.git"

THIRD_REPO = "Timesheet"
THIRD_SLUG = f"{INSTALL_ORG}/{THIRD_REPO}"

# tests/conftest.py installs an autouse fixture
# (mutation_profile_fixture.install_deterministic_remote_urls) that *permanently
# reassigns* srv._local_git_remote_url to a stub mapping remote names to fixed
# URLs. That stub deliberately ignores the working directory, which is exactly
# the behaviour this module must verify — so these tests would silently assert
# against the stub rather than production code in a full-suite run. Capture the
# genuine implementation at import time (before any fixture executes) and
# reinstall it per test.
_REAL_LOCAL_GIT_REMOTE_URL = srv._local_git_remote_url


def _git(cwd: str, *args: str) -> str:
    res = subprocess.run(
        ["git", "-C", cwd, *args], capture_output=True, text=True, check=True
    )
    return res.stdout.strip()


def _init_repo(path: Path, remote_url: str, *, remote_name: str = "origin") -> str:
    path.mkdir(parents=True, exist_ok=True)
    _git(str(path), "init", "-q")
    _git(str(path), "config", "user.email", "t@example.com")
    _git(str(path), "config", "user.name", "Test")
    _git(str(path), "remote", "add", remote_name, remote_url)
    # Distinct content per repo: identical seed content, author and timestamp
    # otherwise produce byte-identical commits and therefore an identical SHA,
    # which would make the parity-dimension assertions vacuously true.
    (path / "README.md").write_text(f"seed {remote_url}\n")
    _git(str(path), "add", "README.md")
    _git(str(path), "commit", "-q", "-m", f"seed {remote_name}")
    return os.path.realpath(str(path))


_ROLE_OPS = {
    "author": (
        [
            "gitea.read",
            "gitea.issue.create",
            "gitea.issue.comment",
            "gitea.pr.create",
            "gitea.pr.comment",
            "gitea.branch.create",
            "gitea.branch.push",
            "gitea.repo.commit",
        ],
        ["gitea.pr.approve", "gitea.pr.merge", "gitea.pr.request_changes"],
    ),
    "reviewer": (
        [
            "gitea.read",
            "gitea.pr.approve",
            "gitea.pr.request_changes",
            "gitea.pr.comment",
            "gitea.issue.comment",
        ],
        ["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"],
    ),
    "merger": (
        ["gitea.read", "gitea.pr.merge", "gitea.pr.comment"],
        [
            "gitea.pr.create",
            "gitea.branch.push",
            "gitea.pr.approve",
            "gitea.pr.request_changes",
        ],
    ),
    "reconciler": (
        ["gitea.read", "gitea.pr.close", "gitea.pr.comment", "gitea.branch.delete"],
        [
            "gitea.pr.create",
            "gitea.branch.push",
            "gitea.pr.approve",
            "gitea.pr.merge",
            "gitea.pr.request_changes",
        ],
    ),
}

ROLES = tuple(_ROLE_OPS)


def _profile(role: str, *, canonical_root: str | None = None) -> dict:
    allowed, forbidden = _ROLE_OPS[role]
    profile = {
        "enabled": True,
        "context": "prgs",
        "role": role,
        "username": "jcwalker3",
        "base_url": "https://gitea.prgs.cc",
        "auth": {"type": "env", "name": f"GITEA_TOKEN_PRGS_{role.upper()}"},
        "allowed_operations": list(allowed),
        "forbidden_operations": list(forbidden),
        "execution_profile": f"prgs-{role}",
        "allowed_repositories": [TARGET_SLUG],
    }
    if canonical_root is not None:
        profile["canonical_repository_root"] = canonical_root
    return profile


def _config(profiles: dict) -> dict:
    return {
        "version": 2,
        "rules": {"allow_runtime_switching": True},
        "contexts": {
            "prgs": {
                "enabled": True,
                "gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
            }
        },
        "profiles": profiles,
    }


class _CrossRepoHarness(unittest.TestCase):
    """Real install + target git repos, temp profiles.json, no network."""

    def setUp(self):
        self._dir = tempfile.TemporaryDirectory()
        self.tmp = self._dir.name
        self.config_path = os.path.join(self.tmp, "profiles.json")

        # The real install checkout carries a remote literally named `prgs`;
        # a freshly cloned target repository normally names its remote `origin`.
        # Reproducing that asymmetry is the point: identity derivation must not
        # depend on the remote happening to share the `remote=` argument's name.
        self.install_root = _init_repo(
            Path(self.tmp) / "install", INSTALL_URL, remote_name="prgs"
        )
        self.target_root = _init_repo(
            Path(self.tmp) / "target", TARGET_URL, remote_name="origin"
        )
        self.third_root = _init_repo(
            Path(self.tmp) / "third", f"https://gitea.prgs.cc/{THIRD_SLUG}.git"
        )
        self.not_a_repo = os.path.join(self.tmp, "plain-dir")
        os.makedirs(self.not_a_repo, exist_ok=True)

        session_ctx._reset_session_context_for_testing()
        gitea_config._active_profile_override = None
        mcp_server._IDENTITY_CACHE.clear()
        srv._MUTATION_AUTHORITY = None

        # PROJECT_ROOT is wherever the server file physically lives; pin it to a
        # real Gitea-Tools-identified checkout so "install-derived" is
        # deterministic regardless of the developer's layout.
        self._project_root = patch.object(srv, "PROJECT_ROOT", self.install_root)
        self._project_root.start()
        # Undo the autouse deterministic-remote stub for this module only.
        self._real_remote_url = patch.object(
            srv, "_local_git_remote_url", _REAL_LOCAL_GIT_REMOTE_URL
        )
        self._real_remote_url.start()

    def tearDown(self):
        self._real_remote_url.stop()
        self._project_root.stop()
        session_ctx._reset_session_context_for_testing()
        gitea_config._active_profile_override = None
        mcp_server._IDENTITY_CACHE.clear()
        srv._MUTATION_AUTHORITY = None
        self._dir.cleanup()

    def _write_config(self, profiles: dict) -> None:
        with open(self.config_path, "w", encoding="utf-8") as fh:
            fh.write(json.dumps(_config(profiles)))

    def _env(self, role: str, **extra) -> dict:
        env = {
            "GITEA_MCP_CONFIG": self.config_path,
            "GITEA_MCP_PROFILE": f"prgs-{role}",
            "GITEA_TOKEN_PRGS_AUTHOR": "t",
            "GITEA_TOKEN_PRGS_REVIEWER": "t",
            "GITEA_TOKEN_PRGS_MERGER": "t",
            "GITEA_TOKEN_PRGS_RECONCILER": "t",
        }
        env.update(extra)
        return env

    def _bind(self, role: str, canonical_root: str | None):
        """Activate *role* with *canonical_root* and return an env patch ctx."""
        self._write_config(
            {f"prgs-{role}": _profile(role, canonical_root=canonical_root)}
        )
        return patch.dict(os.environ, self._env(role), clear=True)


# ===========================================================================
# 1. The central helper (single source of root resolution)
# ===========================================================================
class TestCanonicalLocalGitRoot(_CrossRepoHarness):
    """_canonical_local_git_root is the one place a target root is derived."""

    def test_unconfigured_namespace_uses_installation_root(self):
        with self._bind("author", None):
            self.assertEqual(srv._canonical_local_git_root(), self.install_root)

    def test_configured_namespace_uses_target_root(self):
        with self._bind("author", self.target_root):
            self.assertEqual(srv._canonical_local_git_root(), self.target_root)

    def test_configured_root_resolves_to_git_toplevel(self):
        nested = os.path.join(self.target_root, "branches", "wt")
        os.makedirs(nested, exist_ok=True)
        with self._bind("author", nested):
            # A subdirectory of the target repo still resolves to its toplevel.
            self.assertEqual(srv._canonical_local_git_root(), self.target_root)

    def test_invalid_root_never_silently_becomes_installation_root(self):
        """A configured-but-broken root must not fall back to Gitea-Tools."""
        with self._bind("author", self.not_a_repo):
            resolved = srv._canonical_local_git_root()
            self.assertNotEqual(resolved, self.install_root)
            self.assertEqual(resolved, os.path.realpath(self.not_a_repo))

    def test_env_override_beats_profile_binding(self):
        self._write_config(
            {"prgs-author": _profile("author", canonical_root=self.third_root)}
        )
        env = self._env("author", GITEA_CANONICAL_REPOSITORY_ROOT=self.target_root)
        with patch.dict(os.environ, env, clear=True):
            self.assertEqual(srv._canonical_local_git_root(), self.target_root)


# ===========================================================================
# 2. Repository identity — the upstream inversion
# ===========================================================================
class TestRepositoryIdentityDerivation(_CrossRepoHarness):
    """_local_git_remote_url and its consumers must read the target repo."""

    def test_remote_url_reads_target_not_installation(self):
        with self._bind("author", self.target_root):
            self.assertEqual(srv._local_git_remote_url("origin"), TARGET_URL)

    def test_remote_url_unconfigured_still_reads_installation(self):
        with self._bind("author", None):
            # The install checkout's remote is named `prgs`, matching production.
            self.assertEqual(srv._local_git_remote_url("prgs"), INSTALL_URL)

    def test_workspace_slug_follows_canonical_root(self):
        with self._bind("author", self.target_root):
            self.assertEqual(srv._workspace_repository_slug("origin"), TARGET_SLUG)

    def test_workspace_slug_unconfigured_is_installation(self):
        with self._bind("author", None):
            self.assertEqual(srv._workspace_repository_slug("prgs"), INSTALL_SLUG)

    def test_every_role_derives_the_same_target_identity(self):
        for role in ROLES:
            with self.subTest(role=role):
                with self._bind(role, self.target_root):
                    self.assertEqual(
                        srv._workspace_repository_slug("origin"), TARGET_SLUG
                    )


# ===========================================================================
# 3. _resolve — omitted and explicit coordinates
# ===========================================================================
class TestResolveTargetCoordinates(_CrossRepoHarness):
    """Omitted coordinates follow the binding; explicit ones may only confirm."""

    def test_omitted_coordinates_resolve_to_target_repository(self):
        for role in ROLES:
            with self.subTest(role=role):
                with self._bind(role, self.target_root):
                    _host, org, repo = srv._resolve("prgs", None, None, None)
                    self.assertEqual((org, repo), (TARGET_ORG, TARGET_REPO))

    def test_omitted_coordinates_unconfigured_resolve_to_installation(self):
        with self._bind("author", None):
            _host, org, repo = srv._resolve("prgs", None, None, None)
            self.assertEqual((org, repo), (INSTALL_ORG, INSTALL_REPO))

    def test_explicit_matching_coordinates_confirm_the_binding(self):
        for role in ROLES:
            with self.subTest(role=role):
                with self._bind(role, self.target_root):
                    _host, org, repo = srv._resolve(
                        "prgs", None, TARGET_ORG, TARGET_REPO
                    )
                    self.assertEqual((org, repo), (TARGET_ORG, TARGET_REPO))

    def test_explicit_mismatched_repository_cannot_override_binding(self):
        for role in ROLES:
            with self.subTest(role=role):
                with self._bind(role, self.target_root):
                    with self.assertRaises(RuntimeError) as ctx:
                        srv._resolve("prgs", None, TARGET_ORG, INSTALL_REPO)
                    msg = str(ctx.exception)
                    self.assertIn("#741", msg)
                    self.assertIn("fail closed", msg)

    def test_explicit_mismatched_organization_cannot_override_binding(self):
        with self._bind("author", self.target_root):
            with self.assertRaises(RuntimeError):
                srv._resolve("prgs", None, "SomeoneElse", TARGET_REPO)

    def test_gitea_tools_rooted_namespace_cannot_target_control_plane(self):
        """Direction 1: an install-rooted namespace must not reach the target."""
        with self._bind("author", self.install_root):
            with self.assertRaises(RuntimeError):
                srv._resolve("prgs", None, TARGET_ORG, TARGET_REPO)

    def test_control_plane_rooted_namespace_cannot_target_gitea_tools(self):
        """Direction 2: a target-rooted namespace must not reach Gitea-Tools."""
        with self._bind("author", self.target_root):
            with self.assertRaises(RuntimeError):
                srv._resolve("prgs", None, INSTALL_ORG, INSTALL_REPO)

    def test_unconfigured_namespace_keeps_existing_explicit_behaviour(self):
        """Same-repository behaviour is unchanged (no new fail-closed path)."""
        with self._bind("author", None):
            _host, org, repo = srv._resolve("prgs", None, INSTALL_ORG, INSTALL_REPO)
            self.assertEqual((org, repo), (INSTALL_ORG, INSTALL_REPO))


# ===========================================================================
# 4. #274 workspace guard — the two guard paths must agree
# ===========================================================================
class TestMutationWorkspaceGuardBinding(_CrossRepoHarness):
    """Both guard paths must agree on which repository they protect."""

    def _contexts(self, worktree: str | None = None):
        return srv._resolve_namespace_mutation_context(worktree)

    def test_mutation_context_uses_target_root(self):
        with self._bind("author", self.target_root):
            self.assertEqual(self._contexts()["canonical_repo_root"], self.target_root)

    def test_mutation_context_unconfigured_uses_installation_root(self):
        with self._bind("author", None):
            self.assertEqual(self._contexts()["canonical_repo_root"], self.install_root)

    def test_role_mutation_workspace_guard_agrees_with_mutation_context(self):
        """The omitted configured_canonical_root made these two disagree."""
        import namespace_workspace_binding as nwb

        for role in ROLES:
            with self.subTest(role=role):
                with self._bind(role, self.target_root):
                    configured, _src = srv._configured_canonical_root()
                    assessment = nwb.assess_namespace_mutation_workspace(
                        role_kind=role,
                        worktree_path=None,
                        worktree=None,
                        process_project_root=srv.PROJECT_ROOT,
                        profile_name=f"prgs-{role}",
                        configured_canonical_root=configured,
                    )
                    self.assertEqual(
                        assessment["canonical_repo_root"],
                        self._contexts()["canonical_repo_root"],
                    )
                    self.assertEqual(
                        assessment["canonical_repo_root"], self.target_root
                    )

    def test_wrong_worktree_is_rejected_against_target_root(self):
        """A worktree belonging to the install repo is not in the target repo."""
        import author_mutation_worktree as amw

        with self._bind("author", self.target_root):
            foreign = os.path.join(self.install_root, "branches", "wt")
            os.makedirs(foreign, exist_ok=True)
            membership = amw.assess_workspace_repo_membership(
                workspace_path=foreign,
                canonical_repo_root=self.target_root,
            )
            self.assertTrue(membership["block"])


# ===========================================================================
# 5. Canonical-root validation — missing / invalid / mismatched
# ===========================================================================
class TestCanonicalRootFailClosed(_CrossRepoHarness):
    """Missing, invalid, ambiguous and mismatched roots fail closed."""

    def _assess(self, value, *, expected=TARGET_SLUG, require=True):
        return crr.assess_canonical_repository_root(
            configured_value=value,
            source="test",
            expected_slug=expected,
            process_project_root=self.install_root,
            remote="origin",
            require_binding=require,
        )

    def test_missing_root_fails_closed_when_binding_required(self):
        result = self._assess(None)
        self.assertTrue(result["block"])
        self.assertFalse(result["configured"])

    def test_missing_root_is_the_single_repo_default_when_not_required(self):
        result = self._assess(None, expected=None, require=False)
        self.assertFalse(result["block"])
        self.assertEqual(result["canonical_repo_root"], self.install_root)

    def test_nonexistent_root_fails_closed(self):
        result = self._assess(os.path.join(self.tmp, "nope"))
        self.assertTrue(result["block"])
        self.assertIn("does not exist", " ".join(result["reasons"]))

    def test_non_git_directory_fails_closed(self):
        result = self._assess(self.not_a_repo)
        self.assertTrue(result["block"])
        self.assertIn("not a git repository", " ".join(result["reasons"]))

    def test_mismatched_repository_identity_fails_closed(self):
        result = self._assess(self.third_root)
        self.assertTrue(result["block"])
        self.assertIn("mismatch", " ".join(result["reasons"]))

    def test_matching_repository_identity_is_proven(self):
        result = self._assess(self.target_root)
        self.assertFalse(result["block"])
        self.assertEqual(result["resolved_slug"], TARGET_SLUG)
        self.assertEqual(result["canonical_repo_root"], self.target_root)

    def test_unresolvable_root_yields_fail_closed_reasons_not_fallback(self):
        with self._bind("author", self.not_a_repo):
            slug, reasons = srv._canonical_repository_slug(srv.get_profile(), "origin")
            self.assertIsNone(slug)
            self.assertTrue(reasons)


# ===========================================================================
# 6. Immutability — first bind wins, requests cannot replace the root
# ===========================================================================
class TestCanonicalRootImmutability(_CrossRepoHarness):
    """No request-supplied value can establish or swap the pinned root."""

    def test_first_bind_pins_target_root(self):
        with self._bind("author", self.target_root):
            with patch(
                "gitea_mcp_server.api_request",
                side_effect=lambda *a, **k: {
                    "login": "jcwalker3",
                    "full_name": "T",
                    "id": 1,
                    "email": "t@example.com",
                },
            ):
                srv.gitea_whoami(remote="prgs")
            bound = session_ctx.get_session_context()
            self.assertEqual(bound["canonical_repository_root"], self.target_root)

    def test_binding_is_first_write_wins(self):
        with self._bind("author", self.target_root):
            session_ctx.seed_session_context_if_unbound(
                profile_name="prgs-author",
                remote="prgs",
                host="gitea.prgs.cc",
                identity="jcwalker3",
                org=TARGET_ORG,
                repository=TARGET_REPO,
                role_kind="author",
                source="test",
                canonical_repository_root=self.target_root,
            )
            # A second, contradictory seed must not replace the pin.
            session_ctx.seed_session_context_if_unbound(
                profile_name="prgs-author",
                remote="prgs",
                host="gitea.prgs.cc",
                identity="jcwalker3",
                org=INSTALL_ORG,
                repository=INSTALL_REPO,
                role_kind="author",
                source="test-2",
                canonical_repository_root=self.install_root,
            )
            bound = session_ctx.get_session_context()
            self.assertEqual(bound["canonical_repository_root"], self.target_root)
            self.assertEqual(bound["repository"], TARGET_REPO)

    def test_request_supplied_worktree_cannot_replace_the_root(self):
        with self._bind("author", self.target_root):
            ctx = srv._resolve_namespace_mutation_context(self.install_root)
            # The workspace argument may be demoted/inspected, but the canonical
            # repository root stays the configured target.
            self.assertEqual(ctx["canonical_repo_root"], self.target_root)

    def test_request_supplied_coordinates_cannot_replace_the_root(self):
        with self._bind("author", self.target_root):
            with self.assertRaises(RuntimeError):
                srv._resolve("prgs", None, INSTALL_ORG, INSTALL_REPO)
            self.assertEqual(srv._canonical_local_git_root(), self.target_root)


# ===========================================================================
# 7. Parity dimensions stay separately labelled (#739 F3 preserved)
# ===========================================================================
class TestParityDimensionSeparation(_CrossRepoHarness):
    """Server-implementation parity stays anchored to the install checkout."""

    def test_server_dimension_is_installation_not_target(self):
        import master_parity_gate

        with self._bind("author", self.target_root):
            head = master_parity_gate.read_git_head(srv.PROJECT_ROOT)
            self.assertEqual(head, _git(self.install_root, "rev-parse", "HEAD"))
            self.assertNotEqual(head, _git(self.target_root, "rev-parse", "HEAD"))

    def test_target_dimension_reads_the_target_checkout(self):
        with self._bind("author", self.target_root):
            self.assertEqual(
                _git(srv._canonical_local_git_root(), "rev-parse", "HEAD"),
                _git(self.target_root, "rev-parse", "HEAD"),
            )


# ===========================================================================
# 8. Configuration loaders validate the binding consistently (AC13)
# ===========================================================================
class TestConfigurationLoaderValidation(unittest.TestCase):
    """Every supported loader treats canonical_repository_root identically."""

    def setUp(self):
        self._dir = tempfile.TemporaryDirectory()
        self.tmp = self._dir.name

    def tearDown(self):
        self._dir.cleanup()

    def _write(self, data: dict) -> str:
        path = os.path.join(self.tmp, "profiles.json")
        with open(path, "w", encoding="utf-8") as fh:
            fh.write(json.dumps(data))
        return path

    # --- v1 ---------------------------------------------------------------
    def _v1(self, root):
        return {
            "version": 1,
            "profiles": {
                "prgs-author": {
                    "base_url": "https://gitea.prgs.cc",
                    "auth": {"type": "env", "name": "GITEA_TOKEN"},
                    "canonical_repository_root": root,
                }
            },
        }

    def test_v1_rejects_relative_canonical_root(self):
        path = self._write(self._v1("relative/path"))
        with self.assertRaises(gitea_config.ConfigError) as ctx:
            gitea_config.load_config(path)
        self.assertIn("absolute", str(ctx.exception))

    def test_v1_rejects_blank_canonical_root(self):
        path = self._write(self._v1("   "))
        with self.assertRaises(gitea_config.ConfigError):
            gitea_config.load_config(path)

    def test_v1_accepts_absolute_canonical_root(self):
        path = self._write(self._v1("/abs/target"))
        loaded = gitea_config.load_config(path)
        self.assertEqual(
            loaded["profiles"]["prgs-author"]["canonical_repository_root"],
            "/abs/target",
        )

    def test_v1_without_the_field_is_unchanged(self):
        data = self._v1("/abs/target")
        del data["profiles"]["prgs-author"]["canonical_repository_root"]
        loaded = gitea_config.load_config(self._write(data))
        self.assertNotIn("canonical_repository_root", loaded["profiles"]["prgs-author"])

    # --- v2 environments --------------------------------------------------
    def _v2_env(self, root):
        ident = {
            "auth": {"type": "env", "name": "GITEA_TOKEN"},
            "base_url": "https://gitea.prgs.cc",
            "username": "jcwalker3",
            "allowed_operations": ["gitea.read"],
        }
        if root is not None:
            ident["canonical_repository_root"] = root
        return {
            "version": 2,
            "environments": {
                "prgs": {"services": {"gitea": {"identities": {"author": ident}}}}
            },
        }

    def test_v2_environments_propagates_canonical_root(self):
        """Regression: the field was silently dropped during flattening."""
        loaded = gitea_config.load_config(self._write(self._v2_env("/abs/target")))
        profile = loaded["profiles"]["prgs.gitea.author"]
        self.assertEqual(profile["canonical_repository_root"], "/abs/target")

    def test_v2_environments_rejects_relative_canonical_root(self):
        with self.assertRaises(gitea_config.ConfigError) as ctx:
            gitea_config.load_config(self._write(self._v2_env("relative/path")))
        self.assertIn("absolute", str(ctx.exception))

    def test_v2_environments_without_the_field_is_unchanged(self):
        loaded = gitea_config.load_config(self._write(self._v2_env(None)))
        self.assertNotIn(
            "canonical_repository_root", loaded["profiles"]["prgs.gitea.author"]
        )

    # --- v2 contexts (already validated; guard against regression) ---------
    def test_v2_contexts_still_rejects_relative_canonical_root(self):
        data = {
            "version": 2,
            "contexts": {
                "prgs": {
                    "enabled": True,
                    "gitea": {"enabled": True, "base_url": "https://gitea.prgs.cc"},
                }
            },
            "profiles": {
                "prgs-author": {
                    "enabled": True,
                    "context": "prgs",
                    "username": "jcwalker3",
                    "auth": {"type": "env", "name": "GITEA_TOKEN"},
                    "allowed_operations": ["gitea.read"],
                    "canonical_repository_root": "relative/path",
                }
            },
        }
        with self.assertRaises(gitea_config.ConfigError):
            gitea_config.load_config(self._write(data))


# ===========================================================================
# 9. Role-by-operation matrix
# ===========================================================================
class TestRoleOperationMatrix(_CrossRepoHarness):
    """Each role, each cross-repository condition, one assertion per cell."""

    def test_correct_target_resolves_for_every_role(self):
        for role in ROLES:
            with self.subTest(role=role, case="correct-target"):
                with self._bind(role, self.target_root):
                    _h, org, repo = srv._resolve("prgs", None, None, None)
                    self.assertEqual((org, repo), (TARGET_ORG, TARGET_REPO))

    def test_wrong_repository_is_rejected_for_every_role(self):
        for role in ROLES:
            with self.subTest(role=role, case="wrong-repo"):
                with self._bind(role, self.target_root):
                    with self.assertRaises(RuntimeError):
                        srv._resolve("prgs", None, INSTALL_ORG, INSTALL_REPO)

    def test_explicit_matching_coordinates_pass_for_every_role(self):
        for role in ROLES:
            with self.subTest(role=role, case="explicit-match"):
                with self._bind(role, self.target_root):
                    _h, org, repo = srv._resolve("prgs", None, TARGET_ORG, TARGET_REPO)
                    self.assertEqual((org, repo), (TARGET_ORG, TARGET_REPO))

    def test_explicit_mismatch_fails_closed_for_every_role(self):
        for role in ROLES:
            with self.subTest(role=role, case="explicit-mismatch"):
                with self._bind(role, self.target_root):
                    with self.assertRaises(RuntimeError):
                        srv._resolve("prgs", None, INSTALL_ORG, THIRD_REPO)

    def test_missing_canonical_root_preserves_single_repo_default(self):
        for role in ROLES:
            with self.subTest(role=role, case="missing-root"):
                with self._bind(role, None):
                    self.assertEqual(srv._canonical_local_git_root(), self.install_root)

    def test_invalid_canonical_root_never_becomes_install_root(self):
        for role in ROLES:
            with self.subTest(role=role, case="invalid-root"):
                with self._bind(role, self.not_a_repo):
                    self.assertNotEqual(
                        srv._canonical_local_git_root(), self.install_root
                    )

    def test_wrong_worktree_rejected_for_every_role(self):
        import author_mutation_worktree as amw

        foreign = os.path.join(self.install_root, "branches", "foreign")
        os.makedirs(foreign, exist_ok=True)
        for role in ROLES:
            with self.subTest(role=role, case="wrong-worktree"):
                with self._bind(role, self.target_root):
                    membership = amw.assess_workspace_repo_membership(
                        workspace_path=foreign,
                        canonical_repo_root=srv._canonical_local_git_root(),
                    )
                    self.assertTrue(membership["block"])

    def test_request_override_attempt_rejected_for_every_role(self):
        for role in ROLES:
            with self.subTest(role=role, case="request-override"):
                with self._bind(role, self.target_root):
                    with self.assertRaises(RuntimeError):
                        srv._resolve("prgs", None, "Attacker", "evil-repo")
                    self.assertEqual(srv._canonical_local_git_root(), self.target_root)


if __name__ == "__main__":
    unittest.main()
