"""Tests for profiles.json version 2 (#103): environment → service → identity.

Covers: v2 loading + flattening, dotted-path and alias resolution with strict
order (exact alias → exact address → fail closed), legacy v1 names via aliases,
fail-closed validation (missing/unknown version, malformed hierarchy, ambiguous
selectors, TBD-* usernames, reviewer-identity deadlock rule, inline secrets,
missing auth, unnormalizable operations), service-default inheritance, and that
flattened v2 profiles still work with resolve_token. No network, no secrets.
"""
import os
import sys
import copy
import json
import tempfile
import unittest
from unittest.mock import patch

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

import gitea_config  # noqa: E402

FAKE_TOKEN = "fake-token-for-tests"  # not a real credential


def v2_config():
    """A fresh, valid v2 config exercising both environments."""
    return {
        "version": 2,
        "environments": {
            "prgs": {
                "services": {
                    "gitea": {
                        "base_url": "https://gitea.prgs.cc",
                        "default_owner": "Scaled-Tech-Consulting",
                        "identities": {
                            "author": {
                                "role": "author",
                                "username": "jcwalker3",
                                "auth": {"type": "keychain",
                                         "id": "prgs.gitea.author.token"},
                                "execution_profile": "prgs-author",
                                "audit_label": "prgs-author",
                                "allowed_operations": [
                                    "gitea.read", "gitea.issue.create",
                                    "gitea.branch.push", "gitea.pr.create",
                                ],
                                "forbidden_operations": [
                                    "gitea.pr.approve", "gitea.pr.merge",
                                ],
                            },
                            "reviewer": {
                                "role": "reviewer",
                                "username": "sysadmin",
                                "auth": {"type": "env",
                                         "name": "PRGS_REVIEWER_TOKEN"},
                                "execution_profile": "prgs-reviewer",
                                "audit_label": "prgs-reviewer",
                                "default_repo": "Gitea-Tools",
                                "allowed_operations": [
                                    "read", "review", "comment", "approve",
                                    "request_changes", "merge",
                                ],
                                "forbidden_operations": [
                                    "gitea.pr.create", "gitea.branch.push",
                                ],
                            },
                        },
                    },
                },
            },
            "mdcps": {
                "services": {
                    "gitea": {
                        "base_url": "https://gitea.dadeschools.net",
                        "identities": {
                            "author": {
                                "role": "author",
                                "username": "jcwalker3",
                                "auth": {"type": "keychain",
                                         "id": "mdcps.gitea.author.token"},
                                "allowed_operations": ["gitea.read"],
                                "forbidden_operations": [
                                    "gitea.pr.approve", "gitea.pr.merge",
                                ],
                            },
                            "reviewer": {
                                "role": "reviewer",
                                "username": "913443",
                                "auth": {"type": "keychain",
                                         "id": "mdcps.gitea.reviewer.token"},
                                "allowed_operations": [
                                    "gitea.read", "gitea.pr.approve",
                                    "gitea.pr.merge",
                                ],
                                "forbidden_operations": [
                                    "gitea.pr.create", "gitea.branch.push",
                                ],
                            },
                        },
                    },
                    "jenkins": {
                        "base_url": "https://jenkins.dadeschools.net",
                        "identities": {
                            "reader": {
                                "role": "reader",
                                "username": "svc-jenkins-read",
                                "auth": {"type": "keychain",
                                         "id": "mdcps.jenkins.reader.token"},
                                "allowed_operations": ["read", "jenkins.build.read"],
                                "forbidden_operations": ["jenkins.build.trigger"],
                            },
                        },
                    },
                },
            },
        },
        "aliases": {
            "mdcps": "mdcps.gitea.author",
            "prgs-author": "prgs.gitea.author",
            "prgs-reviewer": "prgs.gitea.reviewer",
        },
    }


class _V2Base(unittest.TestCase):
    def setUp(self):
        self._dir = tempfile.TemporaryDirectory()
        self.path = os.path.join(self._dir.name, "profiles.json")
        self._write(v2_config())

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

    def _write(self, obj):
        with open(self.path, "w", encoding="utf-8") as fh:
            fh.write(obj if isinstance(obj, str) else json.dumps(obj))

    def _env(self, profile, **extra):
        env = {"GITEA_MCP_CONFIG": self.path, "GITEA_MCP_PROFILE": profile}
        env.update(extra)
        return env

    def _resolve(self, profile):
        with patch.dict(os.environ, self._env(profile), clear=True):
            return gitea_config.resolve_profile()

    def _load_raises(self, mutate, needle):
        cfg = v2_config()
        mutate(cfg)
        self._write(cfg)
        with patch.dict(os.environ, self._env("prgs.gitea.author"), clear=True):
            with self.assertRaises(gitea_config.ConfigError) as ctx:
                gitea_config.resolve_profile()
        self.assertIn(needle, str(ctx.exception))
        return str(ctx.exception)


# ---------------------------------------------------------------------------
# Happy path: loading, dotted paths, aliases, inheritance
# ---------------------------------------------------------------------------
class TestV2Loads(_V2Base):

    def test_dotted_path_resolution(self):
        p = self._resolve("prgs.gitea.author")
        self.assertEqual(p["base_url"], "https://gitea.prgs.cc")
        self.assertEqual(p["username"], "jcwalker3")
        self.assertEqual(p["profile_path"], "prgs.gitea.author")
        self.assertEqual(p["environment"], "prgs")
        self.assertEqual(p["service"], "gitea")
        self.assertEqual(p["identity"], "author")
        self.assertEqual(p["role"], "author")

    def test_alias_resolution_legacy_names(self):
        for legacy, addr in (
            ("mdcps", "mdcps.gitea.author"),
            ("prgs-author", "prgs.gitea.author"),
            ("prgs-reviewer", "prgs.gitea.reviewer"),
        ):
            p = self._resolve(legacy)
            self.assertEqual(p["profile_path"], addr, legacy)

    def test_service_defaults_inherit_and_identity_overrides(self):
        author = self._resolve("prgs.gitea.author")
        self.assertEqual(author["default_owner"], "Scaled-Tech-Consulting")
        self.assertNotIn("default_repo", author)
        reviewer = self._resolve("prgs.gitea.reviewer")
        self.assertEqual(reviewer["default_owner"], "Scaled-Tech-Consulting")
        self.assertEqual(reviewer["default_repo"], "Gitea-Tools")

    def test_unqualified_ops_normalized_minimally(self):
        reviewer = self._resolve("prgs.gitea.reviewer")
        self.assertIn("gitea.pr.merge", reviewer["allowed_operations"])
        self.assertIn("gitea.read", reviewer["allowed_operations"])
        self.assertNotIn("merge", reviewer["allowed_operations"])
        jenkins = self._resolve("mdcps.jenkins.reader")
        self.assertIn("jenkins.read", jenkins["allowed_operations"])
        self.assertIn("jenkins.build.read", jenkins["allowed_operations"])

    def test_resolve_token_works_on_flattened_profile(self):
        with patch.dict(
            os.environ,
            self._env("prgs.gitea.reviewer", PRGS_REVIEWER_TOKEN=FAKE_TOKEN),
            clear=True,
        ):
            profile = gitea_config.resolve_profile()
            self.assertEqual(gitea_config.resolve_token(profile), FAKE_TOKEN)

    def test_auth_source_name_on_flattened_profile(self):
        p = self._resolve("mdcps.gitea.author")
        self.assertEqual(
            gitea_config.auth_source_name(p), "keychain:mdcps.gitea.author.token"
        )

    def test_v1_config_still_loads(self):
        self._write({
            "version": 1,
            "profiles": {"prgs": {
                "base_url": "https://gitea.prgs.cc",
                "auth": {"type": "keychain", "id": "prgs-gitea-token"},
            }},
        })
        p = self._resolve("prgs")
        self.assertEqual(p["base_url"], "https://gitea.prgs.cc")

    def test_validate_config_accepts_valid_v2(self):
        self.assertEqual(gitea_config.validate_config(v2_config()), [])


# ---------------------------------------------------------------------------
# Fail-closed: selectors
# ---------------------------------------------------------------------------
class TestV2Selectors(_V2Base):

    def test_unknown_selector_fails_closed(self):
        with self.assertRaises(gitea_config.ConfigError) as ctx:
            self._resolve("prgs.gitea")  # partial address — no fuzzy matching
        self.assertIn("not found", str(ctx.exception))

    def test_no_fuzzy_matching_on_near_miss(self):
        with self.assertRaises(gitea_config.ConfigError):
            self._resolve("prgs-reviewers")

    def test_conflicting_alias_and_address_fails_closed(self):
        def mutate(cfg):
            cfg["aliases"]["prgs.gitea.author"] = "prgs.gitea.reviewer"
        self._load_raises(mutate, "conflicting selector")

    def test_alias_to_unknown_target_fails_closed(self):
        def mutate(cfg):
            cfg["aliases"]["ghost"] = "prgs.gitea.nope"
        self._load_raises(mutate, "unknown profile")

    def test_tbd_username_fails_closed_on_selection(self):
        def mutate(cfg):
            cfg["environments"]["mdcps"]["services"]["gitea"]["identities"]["reviewer"]["username"] = "TBD-second-mdcps-user"
        cfg = v2_config()
        mutate(cfg)
        self._write(cfg)
        with patch.dict(os.environ, self._env("mdcps.gitea.reviewer"), clear=True):
            with self.assertRaises(gitea_config.ConfigError) as ctx:
                gitea_config.resolve_profile()
        msg = str(ctx.exception)
        self.assertIn("TBD", msg)
        # Note: after #107 provisioning, real username "913443" is used in live config and happy-path tests.

    def test_tbd_identity_does_not_block_other_identities(self):
        # Same file contains the TBD reviewer; author still resolves.
        p = self._resolve("mdcps.gitea.author")
        self.assertEqual(p["username"], "jcwalker3")


# ---------------------------------------------------------------------------
# Fail-closed: structure and versions
# ---------------------------------------------------------------------------
class TestV2Structure(_V2Base):

    def test_missing_version_fails_closed(self):
        def mutate(cfg):
            del cfg["version"]
        self._load_raises(mutate, "version")

    def test_unknown_version_fails_closed(self):
        def mutate(cfg):
            cfg["version"] = 3
        self._load_raises(mutate, "unsupported version")

    def test_missing_environments_fails_closed(self):
        def mutate(cfg):
            del cfg["environments"]
        self._load_raises(mutate, "environments")

    def test_malformed_environment_fails_closed(self):
        def mutate(cfg):
            cfg["environments"]["prgs"] = "not-an-object"
        self._load_raises(mutate, "must be a JSON object")

    def test_missing_services_fails_closed(self):
        def mutate(cfg):
            cfg["environments"]["prgs"]["services"] = {}
        self._load_raises(mutate, "services")

    def test_missing_identities_fails_closed(self):
        def mutate(cfg):
            cfg["environments"]["prgs"]["services"]["gitea"]["identities"] = {}
        self._load_raises(mutate, "identities")

    def test_dotted_segment_name_fails_closed(self):
        def mutate(cfg):
            envs = cfg["environments"]
            envs["bad.env"] = copy.deepcopy(envs["prgs"])
        self._load_raises(mutate, "invalid environment name")

    def test_missing_base_url_fails_closed(self):
        def mutate(cfg):
            svc = cfg["environments"]["prgs"]["services"]["gitea"]
            del svc["base_url"]
        self._load_raises(mutate, "base_url")


# ---------------------------------------------------------------------------
# Fail-closed: identity invariants
# ---------------------------------------------------------------------------
class TestV2IdentityInvariants(_V2Base):

    def _ident(self, cfg, addr="prgs.gitea.author"):
        env, svc, ident = addr.split(".")
        return cfg["environments"][env]["services"][svc]["identities"][ident]

    def test_missing_auth_fails_closed(self):
        def mutate(cfg):
            del self._ident(cfg)["auth"]
        self._load_raises(mutate, "missing an 'auth' reference")

    def test_inline_secret_in_identity_rejected(self):
        def mutate(cfg):
            self._ident(cfg)["token"] = "oops-not-a-real-secret"
        msg = self._load_raises(mutate, "inline 'token'")
        self.assertNotIn("oops-not-a-real-secret", msg)

    def test_inline_secret_in_auth_rejected(self):
        def mutate(cfg):
            self._ident(cfg)["auth"]["password"] = "oops-not-a-real-secret"
        msg = self._load_raises(mutate, "inline 'password'")
        self.assertNotIn("oops-not-a-real-secret", msg)

    def test_reviewer_deadlock_invariant_enforced(self):
        def mutate(cfg):
            reviewer = self._ident(cfg, "prgs.gitea.reviewer")
            reviewer["forbidden_operations"] = []  # can approve/merge AND create
        msg = self._load_raises(mutate, "deadlock")
        self.assertIn("gitea.pr.create", msg)

    def test_reviewer_deadlock_applies_to_unqualified_merge(self):
        def mutate(cfg):
            author = self._ident(cfg)
            author["allowed_operations"] = ["merge"]  # normalized to gitea.pr.merge
            author["forbidden_operations"] = []
        self._load_raises(mutate, "deadlock")

    def test_unnormalizable_operation_fails_closed(self):
        def mutate(cfg):
            self._ident(cfg)["allowed_operations"] = ["frobnicate"]
        self._load_raises(mutate, "cannot be normalized")

    def test_foreign_namespace_operation_fails_closed(self):
        def mutate(cfg):
            reader = self._ident(cfg, "mdcps.jenkins.reader")
            reader["allowed_operations"] = ["gitea.pr.merge"]
        self._load_raises(mutate, "cannot be normalized")


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