"""Tests for launcher.access module — multi-layer verification."""

from __future__ import annotations

import json
import time
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from launcher.access import (
    _check_identity,
    _domain_of,
    _get_cache_path,
    _get_claude_identity,
    _get_github_emails,
    _is_domain_authorized,
    _is_email_authorized,
    _is_org_authorized,
    _load_allowlist,
    _read_cache,
    _sign_cache,
    _write_cache,
    get_access_details,
    verify_access,
)

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

_EMPTY_AL: dict = {"allowed_domains": [], "allowed_emails": [], "allowed_org_uuids": []}


def _write_claude_config(tmp_path: Path, account: dict | None = None) -> Path:
    config_path = tmp_path / ".claude.json"
    data: dict = {}
    if account is not None:
        data["oauthAccount"] = account
    config_path.write_text(json.dumps(data))
    return config_path


def _write_allowlist(home: Path, *, domains: list | None = None, emails: list | None = None, orgs: list | None = None):
    (home / "access_allowlist.json").write_text(
        json.dumps(
            {
                "allowed_domains": domains or [],
                "allowed_emails": emails or [],
                "allowed_org_uuids": orgs or [],
            }
        )
    )


def _sf_home(tmp_path: Path) -> Path:
    home = tmp_path / ".tribunal"
    home.mkdir()
    return home


# ---------------------------------------------------------------------------
# _get_claude_identity
# ---------------------------------------------------------------------------


class TestGetClaudeIdentity:
    def test_returns_account_when_present(self, tmp_path: Path):
        config = _write_claude_config(tmp_path, {"emailAddress": "a@x.com", "organizationUuid": "org-1"})
        with patch("launcher.access.CLAUDE_CONFIG_PATH", config):
            assert _get_claude_identity() is not None

    def test_returns_none_when_no_file(self, tmp_path: Path):
        with patch("launcher.access.CLAUDE_CONFIG_PATH", tmp_path / "nonexistent.json"):
            assert _get_claude_identity() is None

    def test_returns_none_when_no_account(self, tmp_path: Path):
        config = _write_claude_config(tmp_path, None)
        with patch("launcher.access.CLAUDE_CONFIG_PATH", config):
            assert _get_claude_identity() is None

    def test_returns_none_when_no_email(self, tmp_path: Path):
        config = _write_claude_config(tmp_path, {"organizationUuid": "org-1"})
        with patch("launcher.access.CLAUDE_CONFIG_PATH", config):
            assert _get_claude_identity() is None

    def test_returns_none_when_account_is_string(self, tmp_path: Path):
        """S2: Malformed oauthAccount (not a dict)."""
        config = tmp_path / ".claude.json"
        config.write_text(json.dumps({"oauthAccount": "not-a-dict"}))
        with patch("launcher.access.CLAUDE_CONFIG_PATH", config):
            assert _get_claude_identity() is None

    def test_returns_none_on_corrupt_json(self, tmp_path: Path):
        config = tmp_path / ".claude.json"
        config.write_text("{not valid json")
        with patch("launcher.access.CLAUDE_CONFIG_PATH", config):
            assert _get_claude_identity() is None


# ---------------------------------------------------------------------------
# _get_github_emails
# ---------------------------------------------------------------------------


class TestGetGitHubEmails:
    def test_returns_emails(self):
        mock = MagicMock(returncode=0, stdout="alice@example.com\nbob@thebotclub.ai\n")
        with patch("subprocess.run", return_value=mock):
            assert _get_github_emails() == ["alice@example.com", "bob@thebotclub.ai"]

    def test_returns_empty_on_failure(self):
        mock = MagicMock(returncode=1, stdout="")
        with patch("subprocess.run", return_value=mock):
            assert _get_github_emails() == []

    def test_returns_empty_when_gh_not_installed(self):
        with patch("subprocess.run", side_effect=FileNotFoundError):
            assert _get_github_emails() == []

    def test_returns_empty_on_timeout(self):
        import subprocess

        with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("gh", 10)):
            assert _get_github_emails() == []

    def test_ignores_blank_lines(self):
        mock = MagicMock(returncode=0, stdout="a@b.com\n\n  \nc@d.com\n")
        with patch("subprocess.run", return_value=mock):
            assert _get_github_emails() == ["a@b.com", "c@d.com"]


# ---------------------------------------------------------------------------
# _domain_of
# ---------------------------------------------------------------------------


class TestDomainOf:
    def test_normal_email(self):
        assert _domain_of("alice@example.com") == "example.com"

    def test_case_insensitive(self):
        assert _domain_of("Alice@EXAMPLE.COM") == "example.com"

    def test_no_at_sign(self):
        assert _domain_of("notanemail") is None

    def test_empty_string(self):
        assert _domain_of("") is None

    def test_whitespace_only(self):
        assert _domain_of("   ") is None

    def test_empty_domain(self):
        """I4: user@ should return None."""
        assert _domain_of("user@") is None

    def test_multiple_at_signs(self):
        assert _domain_of("a@b@c.com") == "b@c.com"


# ---------------------------------------------------------------------------
# Authorization checks
# ---------------------------------------------------------------------------


class TestDomainAuthorized:
    def test_builtin_domain(self):
        assert _is_domain_authorized("thebotclub.ai", _EMPTY_AL) is True

    def test_case_insensitive(self):
        assert _is_domain_authorized("THEBOTCLUB.AI", _EMPTY_AL) is True

    def test_unknown_domain(self):
        assert _is_domain_authorized("example.com", _EMPTY_AL) is False

    def test_allowlist_domain(self):
        al = {**_EMPTY_AL, "allowed_domains": ["partner.com"]}
        assert _is_domain_authorized("partner.com", al) is True

    def test_non_string_in_allowlist_skipped(self):
        """S2: Non-string values in allowlist are skipped."""
        al = {**_EMPTY_AL, "allowed_domains": [123, None, True]}
        assert _is_domain_authorized("123", al) is False


class TestEmailAuthorized:
    def test_builtin_email(self):
        assert _is_email_authorized("koshaji@gmail.com", _EMPTY_AL) is True

    def test_builtin_email_case_insensitive(self):
        assert _is_email_authorized("Koshaji@Gmail.COM", _EMPTY_AL) is True

    def test_allowlist_email(self):
        al = {**_EMPTY_AL, "allowed_emails": ["contractor@ext.com"]}
        assert _is_email_authorized("contractor@ext.com", al) is True

    def test_not_authorized(self):
        assert _is_email_authorized("rando@evil.com", _EMPTY_AL) is False

    def test_non_string_in_allowlist_skipped(self):
        al = {**_EMPTY_AL, "allowed_emails": [None, 42]}
        assert _is_email_authorized("none", al) is False


class TestOrgAuthorized:
    def test_builtin_org(self):
        assert _is_org_authorized("e6a10da9-0291-43c0-9587-83af6c68725d", _EMPTY_AL) is True

    def test_allowlist_org(self):
        al = {**_EMPTY_AL, "allowed_org_uuids": ["org-123"]}
        assert _is_org_authorized("org-123", al) is True

    def test_case_insensitive(self):
        al = {**_EMPTY_AL, "allowed_org_uuids": ["ORG-ABC"]}
        assert _is_org_authorized("org-abc", al) is True

    def test_unknown_org(self):
        assert _is_org_authorized("unknown-org", _EMPTY_AL) is False

    def test_non_string_in_allowlist_skipped(self):
        al = {**_EMPTY_AL, "allowed_org_uuids": [False, 0]}
        assert _is_org_authorized("false", al) is False


# ---------------------------------------------------------------------------
# Cache with HMAC
# ---------------------------------------------------------------------------


class TestCache:
    def test_write_and_read_cache(self, tmp_path: Path):
        home = _sf_home(tmp_path)
        with patch.dict("os.environ", {"SKILLFIELD_HOME": str(home)}):
            _write_cache("a@b.com", "anthropic-org", True)
            cache = _read_cache()
        assert cache is not None
        assert cache["email"] == "a@b.com"
        assert cache["method"] == "anthropic-org"
        assert cache["authorized"] is True

    def test_expired_positive_cache(self, tmp_path: Path):
        home = _sf_home(tmp_path)
        with patch.dict("os.environ", {"SKILLFIELD_HOME": str(home)}):
            _write_cache("a@b.com", "anthropic-org", True)
            # Tamper timestamp to make it expired (read, resign)
            path = home / "access_cache.json"
            data = json.loads(path.read_text())
            data.pop("signature")
            data["cached_at"] = time.time() - 25 * 3600
            payload = json.dumps(data, sort_keys=True)
            data["signature"] = _sign_cache(payload)
            path.write_text(json.dumps(data))
            assert _read_cache() is None

    def test_expired_negative_cache(self, tmp_path: Path):
        home = _sf_home(tmp_path)
        with patch.dict("os.environ", {"SKILLFIELD_HOME": str(home)}):
            _write_cache("a@b.com", "none", False)
            path = home / "access_cache.json"
            data = json.loads(path.read_text())
            data.pop("signature")
            data["cached_at"] = time.time() - 6 * 60  # 6 min, past the 5-min TTL
            payload = json.dumps(data, sort_keys=True)
            data["signature"] = _sign_cache(payload)
            path.write_text(json.dumps(data))
            assert _read_cache() is None

    def test_negative_cache_within_ttl(self, tmp_path: Path):
        home = _sf_home(tmp_path)
        with patch.dict("os.environ", {"SKILLFIELD_HOME": str(home)}):
            _write_cache("a@b.com", "none", False)
            cache = _read_cache()
        assert cache is not None
        assert cache["authorized"] is False

    def test_missing_cache(self, tmp_path: Path):
        home = _sf_home(tmp_path)
        with patch.dict("os.environ", {"SKILLFIELD_HOME": str(home)}):
            assert _read_cache() is None

    def test_tampered_cache_rejected(self, tmp_path: Path):
        """I2: Editing JSON directly invalidates the HMAC."""
        home = _sf_home(tmp_path)
        with patch.dict("os.environ", {"SKILLFIELD_HOME": str(home)}):
            _write_cache("a@b.com", "anthropic-org", True)
            path = home / "access_cache.json"
            data = json.loads(path.read_text())
            data["authorized"] = True
            data["email"] = "evil@attacker.com"  # tamper
            path.write_text(json.dumps(data))
            assert _read_cache() is None

    def test_missing_signature_rejected(self, tmp_path: Path):
        home = _sf_home(tmp_path)
        cache = {"email": "a@b.com", "method": "forged", "authorized": True, "cached_at": time.time()}
        (home / "access_cache.json").write_text(json.dumps(cache))
        with patch.dict("os.environ", {"SKILLFIELD_HOME": str(home)}):
            assert _read_cache() is None


# ---------------------------------------------------------------------------
# _check_identity (shared core logic)
# ---------------------------------------------------------------------------


class TestCheckIdentity:
    def test_layer1_org(self):
        identity = {"emailAddress": "a@gmail.com", "organizationUuid": "e6a10da9-0291-43c0-9587-83af6c68725d"}
        ok, method, email = _check_identity(identity, [], _EMPTY_AL)
        assert ok is True
        assert method == "anthropic-org"

    def test_layer2_domain(self):
        identity = {"emailAddress": "a@thebotclub.ai", "organizationUuid": "other"}
        ok, method, email = _check_identity(identity, [], _EMPTY_AL)
        assert ok is True
        assert method == "anthropic-email"

    def test_layer2b_email(self):
        identity = {"emailAddress": "koshaji@gmail.com", "organizationUuid": ""}
        ok, method, email = _check_identity(identity, [], _EMPTY_AL)
        assert ok is True
        assert method == "allowlist"

    def test_layer3_github(self):
        ok, method, email = _check_identity(None, ["bob@thebotclub.ai"], _EMPTY_AL)
        assert ok is True
        assert method == "github-email"

    def test_all_fail(self):
        identity = {"emailAddress": "rando@evil.com", "organizationUuid": "bad"}
        ok, method, email = _check_identity(identity, ["rando@evil.com"], _EMPTY_AL)
        assert ok is False


# ---------------------------------------------------------------------------
# verify_access — integration
# ---------------------------------------------------------------------------


class TestVerifyAccess:
    def test_cached_positive_returns_immediately(self, tmp_path: Path):
        home = _sf_home(tmp_path)
        with patch.dict("os.environ", {"SKILLFIELD_HOME": str(home)}):
            _write_cache("a@sf.com.au", "anthropic-email", True)
            with patch("launcher.access._get_claude_identity") as mock_claude:
                ok, msg = verify_access()
        assert ok is True
        assert "cached" in msg
        mock_claude.assert_not_called()

    def test_cached_negative_returns_denied(self, tmp_path: Path):
        """C2: Negative cache avoids slow gh call."""
        home = _sf_home(tmp_path)
        with patch.dict("os.environ", {"SKILLFIELD_HOME": str(home)}):
            _write_cache("rando@evil.com", "none", False)
            with patch("launcher.access._get_claude_identity") as mock_claude:
                ok, msg = verify_access()
        assert ok is False
        assert "cached" in msg
        mock_claude.assert_not_called()

    def test_layer1_anthropic_org(self, tmp_path: Path):
        home = _sf_home(tmp_path)
        _write_allowlist(home, orgs=["org-sf-123"])
        identity = {"emailAddress": "hani@gmail.com", "organizationUuid": "org-sf-123"}
        with patch.dict("os.environ", {"SKILLFIELD_HOME": str(home)}):
            with patch("launcher.access._get_claude_identity", return_value=identity):
                with patch("launcher.access._get_github_emails", return_value=[]):
                    ok, msg = verify_access()
        assert ok is True
        assert "Anthropic org" in msg

    def test_layer3_github_domain(self, tmp_path: Path):
        home = _sf_home(tmp_path)
        with patch.dict("os.environ", {"SKILLFIELD_HOME": str(home)}):
            with patch("launcher.access._get_claude_identity", return_value=None):
                with patch("launcher.access._get_github_emails", return_value=["bob@thebotclub.ai"]):
                    ok, msg = verify_access()
        assert ok is True
        assert "GitHub" in msg

    def test_all_fail_writes_negative_cache(self, tmp_path: Path):
        home = _sf_home(tmp_path)
        identity = {"emailAddress": "rando@evil.com", "organizationUuid": "bad"}
        with patch.dict("os.environ", {"SKILLFIELD_HOME": str(home)}):
            with patch("launcher.access._get_claude_identity", return_value=identity):
                with patch("launcher.access._get_github_emails", return_value=[]):
                    ok, msg = verify_access()
        assert ok is False
        # Negative cache should exist
        with patch.dict("os.environ", {"SKILLFIELD_HOME": str(home)}):
            cache = _read_cache()
        assert cache is not None
        assert cache["authorized"] is False

    def test_no_identity_sources(self, tmp_path: Path):
        home = _sf_home(tmp_path)
        with patch.dict("os.environ", {"SKILLFIELD_HOME": str(home)}):
            with patch("launcher.access._get_claude_identity", return_value=None):
                with patch("launcher.access._get_github_emails", return_value=[]):
                    ok, msg = verify_access()
        assert ok is False
        assert "No identity sources" in msg

    def test_writes_access_log_on_grant(self, tmp_path: Path):
        """S5: Access log written on authorization."""
        home = _sf_home(tmp_path)
        identity = {"emailAddress": "a@thebotclub.ai", "organizationUuid": ""}
        with patch.dict("os.environ", {"SKILLFIELD_HOME": str(home)}):
            with patch("launcher.access._get_claude_identity", return_value=identity):
                with patch("launcher.access._get_github_emails", return_value=[]):
                    verify_access()
        log_path = home / "access.log"
        assert log_path.is_file()
        entry = json.loads(log_path.read_text().strip().splitlines()[-1])
        assert entry["authorized"] is True

    def test_writes_access_log_on_deny(self, tmp_path: Path):
        """S5: Access log written on denial."""
        home = _sf_home(tmp_path)
        identity = {"emailAddress": "rando@evil.com", "organizationUuid": "bad"}
        with patch.dict("os.environ", {"SKILLFIELD_HOME": str(home)}):
            with patch("launcher.access._get_claude_identity", return_value=identity):
                with patch("launcher.access._get_github_emails", return_value=[]):
                    verify_access()
        log_path = home / "access.log"
        assert log_path.is_file()
        entry = json.loads(log_path.read_text().strip().splitlines()[-1])
        assert entry["authorized"] is False


# ---------------------------------------------------------------------------
# get_access_details
# ---------------------------------------------------------------------------


class TestGetAccessDetails:
    def test_shows_all_sources(self, tmp_path: Path):
        home = _sf_home(tmp_path)
        identity = {"emailAddress": "alice@thebotclub.ai", "displayName": "Alice", "organizationUuid": "org-1"}
        with patch.dict("os.environ", {"SKILLFIELD_HOME": str(home)}):
            with patch("launcher.access._get_claude_identity", return_value=identity):
                with patch("launcher.access._get_github_emails", return_value=["alice@github.com"]):
                    details = get_access_details()
        assert details["authorized"] is True
        assert details["sources"]["anthropic"]["domain_authorized"] is True
        assert len(details["sources"]["github"]) == 1

    def test_no_sources(self, tmp_path: Path):
        home = _sf_home(tmp_path)
        with patch.dict("os.environ", {"SKILLFIELD_HOME": str(home)}):
            with patch("launcher.access._get_claude_identity", return_value=None):
                with patch("launcher.access._get_github_emails", return_value=[]):
                    details = get_access_details()
        assert details["authorized"] is False
        assert details["sources"]["anthropic"] is None
        assert details["sources"]["github"] is None


class TestFilePermissions:
    """Verify cache and log files are written with mode 0o600."""

    def test_write_cache_sets_mode_600(self, tmp_path: Path):
        import os
        import stat
        home = _sf_home(tmp_path)
        with patch.dict("os.environ", {"SKILLFIELD_HOME": str(home)}):
            _write_cache("a@b.com", "anthropic-org", True)
        cache_file = home / "access_cache.json"
        assert stat.S_IMODE(os.stat(cache_file).st_mode) == 0o600
