"""mTLS trust, scoped grants and revocation for the paired desktop node."""

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timezone
import hashlib
import hmac
import json
import os
from pathlib import Path
import string
import threading
import time
from typing import Any, Mapping


@dataclass(frozen=True, slots=True)
class PeerIdentity:
    node_id: str
    name: str
    certificate_fingerprint_sha256: str
    roles: frozenset[str]


class TrustPolicy:
    """In-memory projection of the operator-owned paired-node allowlist."""

    def __init__(
        self,
        identities: Mapping[str, PeerIdentity] | None = None,
        *,
        path: Path | None = None,
    ) -> None:
        self._identities = {key.lower(): value for key, value in (identities or {}).items()}
        self._path = path
        self._revoked: set[str] = set()
        self._lock = threading.RLock()

    @classmethod
    def from_file(cls, path: Path, *, allow_empty: bool = False) -> "TrustPolicy":
        payload = json.loads(path.read_text(encoding="utf-8"))
        nodes = payload.get("nodes") if isinstance(payload, Mapping) else None
        if not isinstance(nodes, list):
            raise ValueError("trust store must contain a nodes array")
        identities: dict[str, PeerIdentity] = {}
        for item in nodes:
            if not isinstance(item, Mapping) or item.get("enabled", True) is False:
                continue
            fingerprint = str(
                item.get("certificate_fingerprint_sha256") or item.get("fingerprint") or ""
            ).lower()
            if len(fingerprint) != 64 or any(character not in "0123456789abcdef" for character in fingerprint):
                raise ValueError("trust store contains an invalid certificate fingerprint")
            node_id = str(item.get("node_id") or "")
            if not node_id:
                raise ValueError("trust store contains a node without node_id")
            roles = (
                item.get("allowed_roles")
                or item.get("roles")
                or item.get("granted_permissions")
                or []
            )
            if not isinstance(roles, list):
                raise ValueError("trusted-node roles must be an array")
            identities[fingerprint] = PeerIdentity(
                node_id=node_id[:160],
                name=str(item.get("name") or node_id)[:240],
                certificate_fingerprint_sha256=fingerprint,
                roles=frozenset(str(role) for role in roles),
            )
        if not identities and not allow_empty:
            raise ValueError("remote listener requires at least one enabled trusted node")
        return cls(identities, path=path)

    def add(self, identity: PeerIdentity) -> None:
        fingerprint = identity.certificate_fingerprint_sha256.lower()
        with self._lock:
            self._identities[fingerprint] = identity
            self._revoked.discard(fingerprint)
            self._persist()

    def identity_for_certificate(self, certificate_der: bytes) -> PeerIdentity | None:
        fingerprint = hashlib.sha256(certificate_der).hexdigest()
        with self._lock:
            if fingerprint in self._revoked:
                return None
            return self._identities.get(fingerprint)

    def identity_for_fingerprint(self, fingerprint: str) -> PeerIdentity | None:
        normalized = fingerprint.lower()
        with self._lock:
            if normalized in self._revoked:
                return None
            return self._identities.get(normalized)

    def revoke(self, fingerprint: str) -> bool:
        normalized = fingerprint.lower()
        with self._lock:
            known = normalized in self._identities
            self._revoked.add(normalized)
            self._identities.pop(normalized, None)
            if known:
                self._persist()
            return known

    def manifest(self) -> dict[str, object]:
        with self._lock:
            return {
                "trusted_peers": len(self._identities),
                "revoked_peers": len(self._revoked),
            }

    def _persist(self) -> None:
        if self._path is None:
            return
        payload = {
            "version": 1,
            "nodes": [
                {
                    "node_id": identity.node_id,
                    "name": identity.name,
                    "certificate_fingerprint_sha256": identity.certificate_fingerprint_sha256,
                    "allowed_roles": sorted(identity.roles),
                    "enabled": True,
                }
                for identity in self._identities.values()
            ],
        }
        temporary = self._path.with_suffix(self._path.suffix + ".tmp")
        temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
        os.replace(temporary, self._path)


class RevocationRegistry:
    """Sticky runtime revocations; restart never silently re-enables a killed process."""

    def __init__(self) -> None:
        self._lock = threading.RLock()
        self._grants: set[str] = set()
        self._sessions: set[str] = set()
        self._all = False

    def revoke_grant(self, grant_id: str) -> None:
        with self._lock:
            self._grants.add(grant_id)

    def revoke_session(self, session_id: str) -> None:
        with self._lock:
            self._sessions.add(session_id)

    def revoke_all(self) -> None:
        with self._lock:
            self._all = True

    def is_revoked(self, *, grant_id: str, session_id: str) -> bool:
        with self._lock:
            return self._all or grant_id in self._grants or session_id in self._sessions

    def as_dict(self) -> dict[str, object]:
        with self._lock:
            return {
                "all": self._all,
                "grant_count": len(self._grants),
                "session_count": len(self._sessions),
            }


class GrantError(ValueError):
    def __init__(self, code: str, detail: str) -> None:
        super().__init__(detail)
        self.code = code
        self.detail = detail


@dataclass(frozen=True, slots=True)
class VerifiedGrant:
    grant_id: str
    node_id: str
    capability: str
    session_id: str
    scope: Mapping[str, Any]
    issued_at: float
    expires_at: float


class GrantAuthority:
    """Verifies conductor-issued HMAC grants without exposing an issuance API."""

    def __init__(
        self,
        key: str | bytes,
        *,
        revocations: RevocationRegistry,
        max_lifetime_seconds: float = 3600.0,
    ) -> None:
        self._key = key.encode("utf-8") if isinstance(key, str) else bytes(key)
        if len(self._key) < 32:
            raise ValueError("grant signing key must contain at least 32 bytes")
        self._revocations = revocations
        self._max_lifetime = max_lifetime_seconds

    def sign(self, grant: Mapping[str, Any]) -> dict[str, Any]:
        """Utility for a trusted conductor and offline contract tests."""

        unsigned = {key: value for key, value in grant.items() if key != "signature"}
        signed = dict(unsigned)
        signed["signature"] = hmac.new(
            self._key,
            self._canonical(unsigned),
            hashlib.sha256,
        ).hexdigest()
        return signed

    def verify(
        self,
        grant: Mapping[str, Any],
        *,
        node_id: str,
        session_id: str,
        capability: str,
        arguments: Mapping[str, Any],
        now: float | None = None,
    ) -> VerifiedGrant:
        required = {
            "grant_id",
            "node_id",
            "capability",
            "session_id",
            "issued_at",
            "expires_at",
            "signature",
        }
        if not required.issubset(grant):
            raise GrantError("invalid_grant", "grant is missing required fields")
        signature = str(grant.get("signature") or "").lower()
        if len(signature) != 64:
            raise GrantError("invalid_grant_signature", "grant signature is malformed")
        unsigned = {key: value for key, value in grant.items() if key != "signature"}
        expected = hmac.new(self._key, self._canonical(unsigned), hashlib.sha256).hexdigest()
        if not hmac.compare_digest(signature.encode("ascii", "ignore"), expected.encode("ascii")):
            raise GrantError("invalid_grant_signature", "grant signature does not match")

        grant_id = str(grant["grant_id"])
        grant_node = str(grant["node_id"])
        grant_session = str(grant["session_id"])
        grant_capability = str(grant["capability"])
        allowed_id = set(string.ascii_letters + string.digits + "_.:-")
        if (
            not 1 <= len(grant_id) <= 160
            or grant_id[0] not in string.ascii_letters + string.digits
            or any(character not in allowed_id for character in grant_id)
        ):
            raise GrantError("invalid_grant_id", "grant_id is invalid")
        if grant_node != node_id:
            raise GrantError("grant_target_mismatch", "grant belongs to another node")
        if grant_session != session_id:
            raise GrantError("grant_session_mismatch", "grant belongs to another session")
        if grant_capability != capability:
            raise GrantError("grant_capability_mismatch", "grant does not allow this capability")

        issued_at = self._timestamp(grant["issued_at"])
        expires_at = self._timestamp(grant["expires_at"])
        timestamp = time.time() if now is None else now
        if issued_at > timestamp + 30:
            raise GrantError("grant_not_yet_valid", "grant issuance time is in the future")
        if expires_at <= timestamp:
            raise GrantError("grant_expired", "grant has expired")
        if expires_at <= issued_at or expires_at - issued_at > self._max_lifetime:
            raise GrantError("invalid_grant_lifetime", "grant lifetime exceeds policy")
        if self._revocations.is_revoked(grant_id=grant_id, session_id=session_id):
            raise GrantError("grant_revoked", "grant or session has been revoked")

        raw_scope = grant.get("path_or_device_scope", grant.get("scope", {}))
        scope = raw_scope if isinstance(raw_scope, Mapping) else {}
        self._verify_scope(capability, scope, arguments)
        return VerifiedGrant(
            grant_id=grant_id,
            node_id=grant_node,
            capability=grant_capability,
            session_id=grant_session,
            scope=scope,
            issued_at=issued_at,
            expires_at=expires_at,
        )

    @staticmethod
    def _canonical(value: Mapping[str, Any]) -> bytes:
        try:
            return json.dumps(
                value,
                ensure_ascii=False,
                sort_keys=True,
                separators=(",", ":"),
                allow_nan=False,
            ).encode("utf-8")
        except (TypeError, ValueError) as exc:
            raise GrantError("invalid_grant", "grant must be canonical JSON") from exc

    @staticmethod
    def _timestamp(value: object) -> float:
        if isinstance(value, bool):
            raise GrantError("invalid_grant_time", "grant time is invalid")
        if isinstance(value, (int, float)):
            return float(value)
        try:
            parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
            if parsed.tzinfo is None:
                parsed = parsed.replace(tzinfo=timezone.utc)
            return parsed.timestamp()
        except (TypeError, ValueError) as exc:
            raise GrantError("invalid_grant_time", "grant time is invalid") from exc

    @staticmethod
    def _verify_scope(
        capability: str,
        scope: Mapping[str, Any],
        arguments: Mapping[str, Any],
    ) -> None:
        if capability in {"files.read", "files.write"}:
            path_value = arguments.get("path") or arguments.get("source")
            prefixes = scope.get("path_prefixes")
            if not isinstance(path_value, str) or not isinstance(prefixes, list) or not prefixes:
                raise GrantError("path_scope_required", "file action requires a scoped path")
            target = os.path.normcase(os.path.realpath(path_value))
            allowed = False
            for prefix in prefixes:
                if not isinstance(prefix, str) or not prefix:
                    continue
                root = os.path.normcase(os.path.realpath(prefix))
                try:
                    allowed = os.path.commonpath((target, root)) == root
                except ValueError:
                    allowed = False
                if allowed:
                    break
            if not allowed:
                raise GrantError("path_outside_grant", "file path is outside the grant scope")
        elif capability == "webcam.capture":
            device_id = arguments.get("device_id")
            devices = scope.get("device_ids")
            if not isinstance(device_id, str) or not isinstance(devices, list) or device_id not in devices:
                raise GrantError("device_outside_grant", "webcam device is outside the grant scope")
        elif capability == "screen.capture":
            requested = (
                "all"
                if arguments.get("all_screens") is True
                else str(arguments.get("screen_id") or "primary")
            )
            screens = scope.get("screen_ids")
            if not isinstance(screens, list) or not screens or requested not in screens:
                raise GrantError("screen_outside_grant", "screen is outside the grant scope")
