"""Tests for profiles.json version 2 *contexts* shape (#120).

The canonical machine config uses ``contexts`` / ``profiles`` / ``projects`` /
``rules`` with explicit ``enabled`` flags. Covers: loading + active-profile
resolution via GITEA_MCP_PROFILE, fail-closed refusal of disabled profiles /
contexts / services / projects, project-to-context mapping, base-URL fallback
from the context's gitea block, keychain-only auth references, LLM-safe audit
output (no endpoint URLs, no keychain ids, no tokens) with an explicit
admin/debug opt-in, v1 compatibility, and the no-silent-fallback rule in
gitea_auth.get_auth_header. No network, no real secrets.
"""
import json
import os
import sys
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
import gitea_auth  # noqa: E402

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


def contexts_config():
    """A fresh, valid v2 contexts-shape config with enabled/disabled entries."""
    return {
        "version": 2,
        "contexts": {
            "prgs": {
                "enabled": True,
                "label": "Local / PRGS",
                "default_owner": "Scaled-Tech-Consulting",
                "gitea": {
                    "enabled": True,
                    "kind": "gitea",
                    "base_url": "https://gitea.prgs.cc",
                },
                "services": {
                    "jenkins": {
                        "enabled": True,
                        "kind": "jenkins",
                        "label": "PRGS Jenkins",
                        "base_url": "https://jenkins.prgs.cc",
                        "auth": {"type": "keychain", "id": "prgs-jenkins-token"},
                        "capabilities": ["read"],
                    },
                    "sentry": {
                        "enabled": False,
                        "kind": "sentry",
                        "label": "PRGS Sentry",
                        "base_url": "",
                        "auth": {"type": "keychain", "id": "prgs-sentry-token"},
                        "capabilities": ["read"],
                    },
                },
            },
            "lab": {
                "enabled": False,
                "gitea": {"enabled": False, "kind": "gitea", "base_url": ""},
                "services": {
                    "jenkins": {
                        "enabled": False,
                        "kind": "jenkins",
                        "label": "Lab Jenkins",
                        "base_url": "http://localhost:8080",
                        "auth": {"type": "keychain", "id": "lab-jenkins-token"},
                        "capabilities": ["read"],
                    },
                },
            },
        },
        "profiles": {
            "prgs-author": {
                "enabled": True,
                "context": "prgs",
                "role": "author",
                "username": "jcwalker3",
                "execution_profile": "prgs-author",
                "audit_label": "prgs-author",
                "base_url": "https://gitea.prgs.cc",
                "auth": {"type": "keychain", "id": "prgs-gitea-author-token"},
                "allowed_operations": [
                    "read", "branch", "commit", "push", "open_pr", "comment",
                ],
                "forbidden_operations": [
                    "approve", "request_changes", "merge",
                ],
            },
            "prgs-reviewer": {
                "enabled": True,
                "context": "prgs",
                "role": "reviewer",
                "username": "sysadmin",
                "execution_profile": "prgs-reviewer",
                "audit_label": "prgs-reviewer",
                # no base_url on purpose: must fall back to context gitea
                "auth": {"type": "keychain", "id": "prgs-gitea-reviewer-token"},
                "allowed_operations": [
                    "read", "review", "comment", "approve",
                    "request_changes", "merge",
                ],
                "forbidden_operations": [
                    "branch", "commit", "push", "open_pr",
                ],
            },
            "retired-author": {
                "enabled": False,
                "context": "prgs",
                "role": "author",
                "username": "jcwalker3",
                "base_url": "https://gitea.prgs.cc",
                "auth": {"type": "keychain", "id": "retired-token-ref"},
                "allowed_operations": ["read"],
                "forbidden_operations": [],
            },
            "lab-author": {
                "enabled": True,
                "context": "lab",
                "role": "author",
                "username": "jcwalker3",
                "base_url": "http://localhost:3000",
                "auth": {"type": "keychain", "id": "lab-gitea-author-token"},
                "allowed_operations": ["read"],
                "forbidden_operations": [],
            },
        },
        "projects": {
            "/repo/one": {
                "enabled": True,
                "context": "prgs",
                "default_owner": "Scaled-Tech-Consulting",
                "default_repo": "One",
                "default_author_profile": "prgs-author",
                "default_reviewer_profile": "prgs-reviewer",
            },
            "/repo/lab": {
                "enabled": False,
                "context": "lab",
            },
        },
        "rules": {
            "disabled_behavior": "report in audits, never act",
            "no_silent_fallback": True,
            "tokens_in_json": False,
            "token_storage": "keychain",
            "hide_service_urls_from_llm": True,
            "hide_keychain_ids_from_llm": True,
            "mcp_resolves_endpoints": True,
        },
    }


def write_config(data):
    """Write *data* to a temp JSON file and return its path."""
    fd, path = tempfile.mkstemp(suffix=".json")
    with os.fdopen(fd, "w", encoding="utf-8") as fh:
        json.dump(data, fh)
    return path


def load(data):
    """Load *data* through gitea_config via a temp file, then clean up."""
    path = write_config(data)
    try:
        return gitea_config.load_config(path)
    finally:
        os.unlink(path)


class LoadContextsShapeTests(unittest.TestCase):
    def test_contexts_shape_loads(self):
        config = load(contexts_config())
        self.assertEqual(config["version"], 2)
        self.assertIn("prgs-author", config["profiles"])
        self.assertIn("prgs-reviewer", config["profiles"])

    def test_active_profile_resolved_from_env(self):
        path = write_config(contexts_config())
        try:
            with patch.dict(os.environ, {
                gitea_config.ENV_CONFIG_PATH: path,
                gitea_config.ENV_PROFILE: "prgs-author",
            }):
                profile = gitea_config.resolve_profile()
        finally:
            os.unlink(path)
        self.assertEqual(profile["username"], "jcwalker3")
        self.assertEqual(profile["base_url"], "https://gitea.prgs.cc")
        self.assertEqual(profile["context"], "prgs")

    def test_base_url_falls_back_to_context_gitea(self):
        profile = gitea_config.select_profile(load(contexts_config()),
                                              "prgs-reviewer")
        self.assertEqual(profile["base_url"], "https://gitea.prgs.cc")

    def test_profile_without_any_base_url_is_refused(self):
        data = contexts_config()
        del data["profiles"]["prgs-author"]["base_url"]
        data["contexts"]["prgs"]["gitea"]["enabled"] = False
        config = load(data)
        with self.assertRaises(gitea_config.ConfigError):
            gitea_config.select_profile(config, "prgs-author")

    def test_v1_config_still_loads(self):
        config = load({
            "version": 1,
            "profiles": {
                "prgs": {
                    "base_url": "https://gitea.prgs.cc",
                    "auth": {"type": "keychain", "id": "prgs-gitea-token"},
                },
            },
        })
        profile = gitea_config.select_profile(config, "prgs")
        self.assertEqual(profile["base_url"], "https://gitea.prgs.cc")

    def test_mixed_contexts_and_environments_rejected(self):
        data = contexts_config()
        data["environments"] = {"x": {"services": {}}}
        with self.assertRaises(gitea_config.ConfigError):
            load(data)

    def test_missing_enabled_flag_is_refused(self):
        data = contexts_config()
        del data["profiles"]["prgs-author"]["enabled"]
        with self.assertRaises(gitea_config.ConfigError) as ctx:
            load(data)
        self.assertIn("enabled", str(ctx.exception))


class DisabledRefusalTests(unittest.TestCase):
    def setUp(self):
        self.config = load(contexts_config())

    def test_disabled_profile_refused(self):
        with self.assertRaises(gitea_config.ConfigError) as ctx:
            gitea_config.select_profile(self.config, "retired-author")
        self.assertIn("disabled", str(ctx.exception))

    def test_profile_in_disabled_context_refused(self):
        with self.assertRaises(gitea_config.ConfigError) as ctx:
            gitea_config.select_profile(self.config, "lab-author")
        self.assertIn("disabled", str(ctx.exception))

    def test_enabled_profile_still_selectable(self):
        profile = gitea_config.select_profile(self.config, "prgs-author")
        self.assertEqual(profile["context"], "prgs")

    def test_disabled_service_refused(self):
        with self.assertRaises(gitea_config.ConfigError) as ctx:
            gitea_config.resolve_service(self.config, "prgs", "sentry")
        self.assertIn("disabled", str(ctx.exception))

    def test_enabled_service_resolves_internally_with_auth_reference(self):
        # Internal resolution keeps the URL + auth reference for MCP's own use;
        # they must never appear in LLM-facing (audit/summary) output.
        service = gitea_config.resolve_service(self.config, "prgs", "jenkins")
        self.assertEqual(service["base_url"], "https://jenkins.prgs.cc")
        self.assertEqual(service["auth"], {"type": "keychain",
                                           "id": "prgs-jenkins-token"})
        self.assertNotIn("token", service)

    def test_service_in_disabled_context_refused(self):
        with self.assertRaises(gitea_config.ConfigError) as ctx:
            gitea_config.resolve_service(self.config, "lab", "jenkins")
        self.assertIn("disabled", str(ctx.exception))

    def test_unknown_service_fails_closed(self):
        with self.assertRaises(gitea_config.ConfigError):
            gitea_config.resolve_service(self.config, "prgs", "nope")


class ProjectMappingTests(unittest.TestCase):
    def setUp(self):
        self.config = load(contexts_config())

    def test_project_maps_to_context(self):
        project = gitea_config.project_for_path(self.config, "/repo/one")
        self.assertEqual(project["context"], "prgs")
        self.assertEqual(project["default_reviewer_profile"], "prgs-reviewer")

    def test_unknown_project_returns_none(self):
        self.assertIsNone(
            gitea_config.project_for_path(self.config, "/repo/unknown"))

    def test_disabled_project_refused(self):
        with self.assertRaises(gitea_config.ConfigError) as ctx:
            gitea_config.project_for_path(self.config, "/repo/lab")
        self.assertIn("disabled", str(ctx.exception))


class SecretHandlingTests(unittest.TestCase):
    def test_inline_profile_token_rejected(self):
        data = contexts_config()
        data["profiles"]["prgs-author"]["token"] = FAKE_TOKEN
        with self.assertRaises(gitea_config.ConfigError) as ctx:
            load(data)
        self.assertNotIn(FAKE_TOKEN, str(ctx.exception))

    def test_inline_service_token_rejected(self):
        data = contexts_config()
        data["contexts"]["prgs"]["services"]["jenkins"]["token"] = FAKE_TOKEN
        with self.assertRaises(gitea_config.ConfigError) as ctx:
            load(data)
        self.assertNotIn(FAKE_TOKEN, str(ctx.exception))

    def test_selected_profile_resolves_token_via_keychain(self):
        profile = gitea_config.select_profile(load(contexts_config()),
                                              "prgs-author")
        token = gitea_config.resolve_token(
            profile, keychain_lookup=lambda item_id: FAKE_TOKEN
            if item_id == "prgs-gitea-author-token" else None)
        self.assertEqual(token, FAKE_TOKEN)


class AuditTests(unittest.TestCase):
    """LLM-facing audit output: enabled/disabled state only — no endpoint
    URLs, no keychain ids, no token values. Admin opt-in reveals endpoints
    and auth source names (never token values)."""

    def setUp(self):
        self.config = load(contexts_config())

    def test_audit_reports_enabled_and_disabled(self):
        report = gitea_config.audit_config(self.config)
        profiles = {p["name"]: p for p in report["profiles"]}
        self.assertTrue(profiles["prgs-author"]["enabled"])
        self.assertFalse(profiles["retired-author"]["enabled"])
        services = {(s["context"], s["name"]): s for s in report["services"]}
        self.assertTrue(services[("prgs", "jenkins")]["enabled"])
        self.assertFalse(services[("prgs", "sentry")]["enabled"])
        self.assertFalse(services[("lab", "jenkins")]["enabled"])

    def test_audit_hides_urls_keychain_ids_and_tokens_by_default(self):
        rendered = json.dumps(gitea_config.audit_config(self.config))
        for leaked in ("https://", "http://", "prgs-gitea-author-token",
                       "prgs-jenkins-token", "base_url", FAKE_TOKEN):
            self.assertNotIn(leaked, rendered)
        # Auth is reported as a status, not a reference.
        report = gitea_config.audit_config(self.config)
        profiles = {p["name"]: p for p in report["profiles"]}
        self.assertEqual(profiles["prgs-author"]["auth"], "keychain")

    def test_audit_admin_optin_reveals_endpoints_but_never_tokens(self):
        report = gitea_config.audit_config(self.config, reveal_endpoints=True)
        rendered = json.dumps(report)
        self.assertIn("https://jenkins.prgs.cc", rendered)
        self.assertIn("keychain:prgs-gitea-author-token", rendered)
        self.assertNotIn(FAKE_TOKEN, rendered)

    def test_audit_works_for_v1_config(self):
        report = gitea_config.audit_config({
            "version": 1,
            "profiles": {
                "prgs": {
                    "base_url": "https://gitea.prgs.cc",
                    "auth": {"type": "keychain", "id": "prgs-gitea-token"},
                },
            },
        })
        profiles = {p["name"]: p for p in report["profiles"]}
        self.assertTrue(profiles["prgs"]["enabled"])
        self.assertEqual(profiles["prgs"]["auth"], "keychain")
        self.assertNotIn("https://", json.dumps(report))


class ServiceSummaryTests(unittest.TestCase):
    """Safe one-line summaries for LLM sessions: label + state only."""

    def setUp(self):
        self.config = load(contexts_config())

    def test_summaries_show_state_without_urls_or_ids(self):
        lines = gitea_config.service_summaries(
            self.config, auth_check=lambda service: True)
        text = "\n".join(lines)
        self.assertIn("PRGS Jenkins: enabled, read-only, authenticated", text)
        self.assertIn("PRGS Sentry: disabled", text)
        self.assertIn("Lab Jenkins: disabled", text)
        for leaked in ("https://", "http://", "keychain",
                       "prgs-jenkins-token"):
            self.assertNotIn(leaked, text)

    def test_summary_reports_missing_auth_without_secrets(self):
        lines = gitea_config.service_summaries(
            self.config, auth_check=lambda service: False)
        text = "\n".join(lines)
        self.assertIn("PRGS Jenkins: enabled, read-only, no credential", text)


class NoSilentFallbackTests(unittest.TestCase):
    def test_broken_config_fails_auth_instead_of_falling_back(self):
        """With GITEA_MCP_CONFIG set but unloadable, auth must fail closed."""
        path = write_config({"version": 2})  # invalid: no contexts/environments
        env = {
            gitea_config.ENV_CONFIG_PATH: path,
            gitea_config.ENV_PROFILE: "prgs-author",
        }
        try:
            with patch.dict(os.environ, env, clear=False), \
                 patch.object(gitea_auth, "get_credentials",
                              return_value=(None, None)):
                for var in ("GITEA_TOKEN", "GITEA_TOKEN_PRGS",
                            "GITEA_TOKEN_DADESCHOOLS"):
                    os.environ.pop(var, None)
                with self.assertRaises(gitea_config.ConfigError):
                    gitea_auth.get_auth_header("https://gitea.prgs.cc")
        finally:
            os.unlink(path)

    def test_env_only_users_unaffected(self):
        """Without GITEA_MCP_CONFIG, a missing token still degrades quietly."""
        env = dict(os.environ)
        env.pop(gitea_config.ENV_CONFIG_PATH, None)
        with patch.dict(os.environ, env, clear=True), \
             patch.object(gitea_auth, "get_credentials",
                          return_value=(None, None)):
            for var in ("GITEA_TOKEN", "GITEA_TOKEN_PRGS",
                        "GITEA_TOKEN_DADESCHOOLS"):
                os.environ.pop(var, None)
            self.assertIsNone(
                gitea_auth.get_auth_header("https://gitea.prgs.cc"))


class ValidateConfigTests(unittest.TestCase):
    def test_valid_contexts_config_has_no_problems(self):
        self.assertEqual(gitea_config.validate_config(contexts_config()), [])

    def test_repo_example_file_validates(self):
        example = __import__("pathlib").Path(__file__).resolve().parent.parent \
            / "gitea-mcp.v2-contexts.example.json"
        with open(example, encoding="utf-8") as fh:
            self.assertEqual(gitea_config.validate_config(json.load(fh)), [])

    def test_repo_example_file_reviewer_vs_merger_boundaries(self):
        example_path = __import__("pathlib").Path(__file__).resolve().parent.parent \
            / "gitea-mcp.v2-contexts.example.json"
        with open(example_path, encoding="utf-8") as fh:
            cfg = json.load(fh)

        # 1. Config includes separate reviewer and merger examples
        self.assertIn("example-reviewer", cfg["profiles"])
        self.assertIn("example-merger", cfg["profiles"])

        reviewer = cfg["profiles"]["example-reviewer"]
        merger = cfg["profiles"]["example-merger"]

        # 2. Example reviewer config does not include merge capability
        reviewer_allowed = reviewer.get("allowed_operations") or []
        reviewer_forbidden = reviewer.get("forbidden_operations") or []

        self.assertNotIn("merge", reviewer_allowed)
        self.assertNotIn("gitea.pr.merge", reviewer_allowed)
        self.assertIn("merge", reviewer_forbidden)

        # 3. Example reviewer can resolve review task, cannot resolve merge task
        # 4. Example merger can resolve merge task
        rev_review_ok, _ = gitea_config.check_operation("gitea.pr.review", reviewer_allowed, reviewer_forbidden)
        rev_merge_ok, _ = gitea_config.check_operation("gitea.pr.merge", reviewer_allowed, reviewer_forbidden)

        merg_allowed = merger.get("allowed_operations") or []
        merg_forbidden = merger.get("forbidden_operations") or []
        merg_merge_ok, _ = gitea_config.check_operation("gitea.pr.merge", merg_allowed, merg_forbidden)

        self.assertTrue(rev_review_ok, "example-reviewer must allow review")
        self.assertFalse(rev_merge_ok, "example-reviewer must not allow merge")
        self.assertTrue(merg_merge_ok, "example-merger must allow merge")

    def test_broken_contexts_config_reports_problems(self):
        data = contexts_config()
        data["profiles"]["prgs-author"]["context"] = "nope"
        problems = gitea_config.validate_config(data)
        self.assertTrue(problems)


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