"""Multi-layer access verification for Tribunal.

Verifies user identity through three layers (in order):
1. Anthropic org membership  — ``~/.claude.json`` organizationUuid
2. Anthropic account email   — ``~/.claude.json`` emailAddress domain
3. GitHub verified emails    — ``gh api /user/emails`` (verified only)

Threat model: These checks prevent accidental use by unauthorized users.
They are NOT resistant to a determined local attacker who has filesystem
write access — ``~/.claude.json``, the cache file, and the allowlist are
all local JSON files.  Layers 1–2 trust Claude Code's OAuth data, and
Layer 3 trusts GitHub's email verification.

Verification results are cached locally for 24 hours (positive) or
5 minutes (negative, to avoid slow launches from repeated ``gh`` calls).
The cache is HMAC-signed with a machine-derived key so that editing the
JSON directly will invalidate it.
"""

from __future__ import annotations

import hashlib
import hmac
import json
import platform
import subprocess
import time
from pathlib import Path
from typing import Any

from launcher.access_policy import load_access_policy
from launcher.config import get_tribunal_home

CACHE_FILE = "access_cache.json"
CACHE_TTL_POSITIVE = 24 * 60 * 60
CACHE_TTL_NEGATIVE = 5 * 60
ACCESS_LOG_FILE = "access.log"

GH_API_TIMEOUT_SECONDS = 10

CLAUDE_CONFIG_PATH = Path.home() / ".claude.json"

# Access control lists loaded from external policy. See docs/ENTERPRISE_INSTALL.md.
# Kept as module-level constants for backward compatibility only.
AUTHORIZED_ORG_UUIDS: list[str] = [
    "e6a10da9-0291-43c0-9587-83af6c68725d",
    "31e78506-3d5c-431a-b463-4ad537468452",
]

AUTHORIZED_DOMAINS: list[str] = [
    "thebotclub.ai",
]

AUTHORIZED_EMAILS: list[str] = []
# Authorized emails should be configured via ~/.tribunal/access-policy.json
# or via the TRIBUNAL_POLICY_URL environment variable.
# See docs/ENTERPRISE_INSTALL.md for configuration details.

ALLOWLIST_FILE = "access_allowlist.json"

_policy_cache: dict | None = None


def _get_policy() -> dict:
    """Load the effective access control policy (cached per process)."""
    global _policy_cache
    if _policy_cache is None:
        _policy_cache = load_access_policy()
    return _policy_cache


def _get_claude_identity() -> dict[str, Any] | None:
    """Read the authenticated identity from Claude Code's OAuth config.

    Returns the ``oauthAccount`` dict from ``~/.claude.json``, or None
    if the file doesn't exist or has no account info.
    """
    try:
        if not CLAUDE_CONFIG_PATH.is_file():
            return None
        data = json.loads(CLAUDE_CONFIG_PATH.read_text(encoding="utf-8"))
        account = data.get("oauthAccount")
        if isinstance(account, dict) and account.get("emailAddress"):
            return account
        return None
    except (json.JSONDecodeError, OSError):
        return None


def _get_github_emails() -> list[str]:
    """Fetch verified email addresses from the user's GitHub account.

    Uses ``gh api /user/emails`` to retrieve only verified emails.
    Returns an empty list if ``gh`` is not installed, not authenticated,
    or the call fails.
    """
    try:
        result = subprocess.run(
            ["gh", "api", "/user/emails", "--jq", "[.[] | select(.verified == true)] | .[].email"],
            capture_output=True,
            text=True,
            timeout=GH_API_TIMEOUT_SECONDS,
        )
        if result.returncode != 0:
            if "needs the" in result.stderr and "scope" in result.stderr:
                import logging

                logging.getLogger(__name__).debug("gh scope error: %s", result.stderr.strip())
            return []
        return [e.strip() for e in result.stdout.strip().splitlines() if e.strip()]
    except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
        return []


_EMPTY_ALLOWLIST: dict[str, Any] = {"allowed_domains": [], "allowed_emails": [], "allowed_org_uuids": []}


def _get_allowlist_path() -> Path:
    """Return the path to the allowlist file."""
    return get_tribunal_home() / ALLOWLIST_FILE


def _load_allowlist() -> dict[str, Any]:
    """Load the access allowlist from disk.

    Returns a dict with keys ``allowed_domains``, ``allowed_emails``,
    and ``allowed_org_uuids``.
    """
    path = _get_allowlist_path()
    if not path.is_file():
        return dict(_EMPTY_ALLOWLIST)

    try:
        with open(path, "r", encoding="utf-8") as fh:
            data = json.load(fh)
        return {
            "allowed_domains": data.get("allowed_domains", []),
            "allowed_emails": data.get("allowed_emails", []),
            "allowed_org_uuids": data.get("allowed_org_uuids", []),
        }
    except (json.JSONDecodeError, OSError):
        return dict(_EMPTY_ALLOWLIST)


def _domain_of(email: str) -> str | None:
    """Extract the lowercase domain from an email address."""
    email = email.lower().strip()
    if "@" not in email:
        return None
    domain = email.split("@", 1)[1]
    return domain if domain else None


def _is_domain_authorized(domain: str, allowlist: dict[str, Any]) -> bool:
    """Check whether *domain* matches the policy or the allowlist."""
    domain = domain.lower()
    policy = _get_policy()
    for d in policy.get("authorized_domains", AUTHORIZED_DOMAINS):
        if domain == d.lower():
            return True
    for d in allowlist.get("allowed_domains", []):
        if isinstance(d, str) and domain == d.lower().strip():
            return True
    return False


def _is_email_authorized(email: str, allowlist: dict[str, Any]) -> bool:
    """Check whether *email* matches the policy or the allowlist."""
    email = email.lower().strip()
    policy = _get_policy()
    for e in policy.get("authorized_emails", AUTHORIZED_EMAILS):
        if email == e.lower():
            return True
    for e in allowlist.get("allowed_emails", []):
        if isinstance(e, str) and email == e.lower().strip():
            return True
    return False


def _is_org_authorized(org_uuid: str, allowlist: dict[str, Any]) -> bool:
    """Check whether *org_uuid* matches the policy or the allowlist."""
    org_uuid = org_uuid.lower().strip()
    policy = _get_policy()
    for o in policy.get("authorized_org_uuids", AUTHORIZED_ORG_UUIDS):
        if org_uuid == o.lower():
            return True
    for o in allowlist.get("allowed_org_uuids", []):
        if isinstance(o, str) and org_uuid == o.lower().strip():
            return True
    return False


def _machine_key() -> bytes:
    """Derive a stable machine-specific key for HMAC signing.

    Uses hostname + username so that copying the cache file to another
    machine (or user account) invalidates it.
    """
    raw = f"{platform.node()}:{Path.home()}"
    return hashlib.sha256(raw.encode()).digest()


def _sign_cache(payload: str) -> str:
    """Return an HMAC-SHA256 hex digest for *payload*."""
    return hmac.new(_machine_key(), payload.encode(), hashlib.sha256).hexdigest()


def _get_cache_path() -> Path:
    """Return the path to the access cache file."""
    return get_tribunal_home() / CACHE_FILE


def _read_cache() -> dict[str, Any] | None:
    """Read the cached verification result.

    Returns the cache dict if valid, not expired, and HMAC-verified.
    Positive results use a 24-hour TTL; negative results use 5 minutes.
    """
    path = _get_cache_path()
    if not path.is_file():
        return None
    try:
        with open(path, "r", encoding="utf-8") as fh:
            cache = json.load(fh)

        stored_sig = cache.pop("signature", "")
        payload = json.dumps(cache, sort_keys=True)
        expected_sig = _sign_cache(payload)
        if not hmac.compare_digest(stored_sig, expected_sig):
            return None

        cached_at = cache.get("cached_at", 0)
        ttl = CACHE_TTL_POSITIVE if cache.get("authorized") else CACHE_TTL_NEGATIVE
        if time.time() - cached_at > ttl:
            return None

        return cache
    except (json.JSONDecodeError, OSError, TypeError):
        return None


def _write_cache(email: str, method: str, authorized: bool) -> None:
    """Write a signed verification result to the cache."""
    path = _get_cache_path()
    path.parent.mkdir(parents=True, exist_ok=True)
    cache = {
        "email": email,
        "method": method,
        "authorized": authorized,
        "cached_at": time.time(),
    }
    payload = json.dumps(cache, sort_keys=True)
    cache["signature"] = _sign_cache(payload)
    try:
        with open(path, "w", encoding="utf-8") as fh:
            json.dump(cache, fh, indent=2)
            fh.write("\n")
        import os

        os.chmod(path, 0o600)
    except OSError:
        pass


def _log_access(authorized: bool, email: str, method: str, sources_summary: str) -> None:
    """Append an entry to the access log file."""
    import os

    path = get_tribunal_home() / ACCESS_LOG_FILE
    entry = {
        "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
        "authorized": authorized,
        "email": email,
        "method": method,
        "sources": sources_summary,
    }
    try:
        with open(path, "a", encoding="utf-8") as fh:
            fh.write(json.dumps(entry) + "\n")
        os.chmod(path, 0o600)
    except OSError:
        pass


def _check_identity(
    identity: dict[str, Any] | None,
    gh_emails: list[str],
    allowlist: dict[str, Any],
) -> tuple[bool, str, str]:
    """Run layered authorization checks against identity sources.

    Returns ``(authorized, method, email)`` where *method* describes
    which layer matched (e.g. ``"anthropic-org"``) and *email* is the
    email that was authorized.
    """
    if identity:
        email = identity["emailAddress"]
        org_uuid = identity.get("organizationUuid", "")

        if org_uuid and _is_org_authorized(org_uuid, allowlist):
            return True, "anthropic-org", email

        domain = _domain_of(email)
        if domain and _is_domain_authorized(domain, allowlist):
            return True, "anthropic-email", email

        if _is_email_authorized(email, allowlist):
            return True, "allowlist", email

    for gh_email in gh_emails:
        domain = _domain_of(gh_email)
        if domain and _is_domain_authorized(domain, allowlist):
            return True, "github-email", gh_email
        if _is_email_authorized(gh_email, allowlist):
            return True, "allowlist", gh_email

    return False, "none", ""


def verify_access() -> tuple[bool, str]:
    """Verify that the current user is authorized to use Tribunal.

    Checks identity sources in order:

    1. **Anthropic org UUID** — ``~/.claude.json  oauthAccount.organizationUuid``
    2. **Anthropic email domain** — ``~/.claude.json  oauthAccount.emailAddress``
    3. **GitHub verified emails** — ``gh api /user/emails``

    Each layer also checks the allowlist for exact-email matches.
    Results are cached: 24 h positive, 5 min negative.
    Returns ``(authorized, message)``.
    """
    cache = _read_cache()
    if cache is not None:
        if cache.get("authorized"):
            method = cache.get("method", "cached")
            email = cache.get("email", "unknown")
            return True, f"Access verified (cached, {method}): {email}"
        return False, "Access denied (cached). Retry in a few minutes or update your identity."

    allowlist = _load_allowlist()
    identity = _get_claude_identity()
    gh_emails = _get_github_emails()

    authorized, method, email = _check_identity(identity, gh_emails, allowlist)

    if authorized:
        _write_cache(email, method, True)
        _log_access(True, email, method, "")
        label = {
            "anthropic-org": "Anthropic org",
            "anthropic-email": "Anthropic account",
            "github-email": "GitHub",
            "allowlist": "allowlist",
        }.get(method, method)
        return True, f"Access verified ({label}): {email}"

    sources: list[str] = []
    if identity:
        sources.append(f"Anthropic: {identity['emailAddress']}")
    if gh_emails:
        sources.append(f"GitHub: {', '.join(gh_emails)}")
    if not sources:
        sources.append("No identity sources found (Claude Code not logged in, gh CLI not available)")

    checked = "; ".join(sources)
    denied_email = identity.get("emailAddress", "") if identity else ""
    _write_cache(denied_email or "unknown", "none", False)
    _log_access(False, denied_email or "unknown", "none", checked)

    return False, (
        f"Access denied.\n"
        f"  Checked: {checked}\n"
        f"  None matched authorized domains or allowlist.\n"
        f"  Contact info@thebotclub.ai for access."
    )


def get_access_details() -> dict[str, Any]:
    """Return a detailed breakdown of all identity sources and their status.

    Used by ``tribunal status`` to show which layers pass/fail.
    Reuses :func:`_check_identity` so authorization logic is not duplicated.
    """
    allowlist = _load_allowlist()
    identity = _get_claude_identity()
    gh_scope_error: str | None = None
    try:
        gh_emails = _get_github_emails()
    except PermissionError as exc:
        gh_emails = []
        gh_scope_error = str(exc)

    authorized, method, _ = _check_identity(identity, gh_emails, allowlist)
    details: dict[str, Any] = {"authorized": authorized, "method": method if authorized else None, "sources": {}}

    cache = _read_cache()
    if cache and cache.get("authorized"):
        remaining = CACHE_TTL_POSITIVE - (time.time() - cache.get("cached_at", 0))
        details["cache"] = {
            "valid": True,
            "email": cache.get("email"),
            "method": cache.get("method"),
            "expires_in_hours": round(remaining / 3600, 1),
        }
    else:
        details["cache"] = {"valid": False}

    if identity:
        email = identity["emailAddress"]
        org_uuid = identity.get("organizationUuid", "")
        org_match = bool(org_uuid and _is_org_authorized(org_uuid, allowlist))
        domain = _domain_of(email) or ""
        domain_match = _is_domain_authorized(domain, allowlist) if domain else False
        email_match = _is_email_authorized(email, allowlist)
        details["sources"]["anthropic"] = {
            "email": email,
            "display_name": identity.get("displayName"),
            "org_uuid": org_uuid or None,
            "org_authorized": org_match,
            "domain_authorized": domain_match,
            "email_authorized": email_match,
            "authorized": org_match or domain_match or email_match,
        }
    else:
        details["sources"]["anthropic"] = None

    if gh_emails:
        gh_results = []
        for gh_email in gh_emails:
            domain = _domain_of(gh_email) or ""
            d_match = _is_domain_authorized(domain, allowlist) if domain else False
            e_match = _is_email_authorized(gh_email, allowlist)
            gh_results.append({"email": gh_email, "domain_authorized": d_match, "email_authorized": e_match})
        details["sources"]["github"] = gh_results
    else:
        details["sources"]["github"] = None
        if gh_scope_error:
            details["sources"]["github_scope_error"] = gh_scope_error

    return details
