"""External context data model for user-supplied enrichment data."""

from __future__ import annotations

import fnmatch
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any


class ExternalContextError(Exception):
    """Raised when external context file is invalid."""


_VALID_TOP_LEVEL_KEYS = {
    "organization", "principals", "resources", "vpc_map", "management_account_id",
}


@dataclass
class ExternalContext:
    """External context data supplied by the user via --context FILE.

    Provides lookup methods for enriching EvaluationContext with data
    not available in CloudTrail events alone (org ID, principal tags,
    resource tags, VPC mappings).
    """

    # Organization-level (applies to all events)
    org_id: str | None = None
    org_paths: list[str] = field(default_factory=list)

    # Principal tags keyed by ARN pattern (supports * wildcards)
    principal_tags: dict[str, dict[str, str]] = field(default_factory=dict)

    # Resource tags keyed by ARN pattern (supports * wildcards)
    resource_tags: dict[str, dict[str, str]] = field(default_factory=dict)

    # VPC endpoint ID -> VPC ID mapping
    vpc_map: dict[str, str] = field(default_factory=dict)

    # Management account ID (SCPs do not apply to this account)
    management_account_id: str | None = None

    @classmethod
    def from_file(cls, path: Path | str) -> ExternalContext:
        """Load and validate an external context file.

        Args:
            path: Path to a JSON context file.

        Returns:
            Populated ExternalContext instance.

        Raises:
            ExternalContextError: If the file is missing, invalid JSON,
                or has an invalid structure.
        """
        path = Path(path)
        if not path.exists():
            raise ExternalContextError(f"Context file not found: {path}")

        try:
            with open(path, encoding="utf-8") as f:
                data = json.load(f)
        except json.JSONDecodeError as e:
            raise ExternalContextError(f"Invalid JSON in context file: {e}") from e

        if not isinstance(data, dict):
            raise ExternalContextError("Context file must contain a JSON object")

        unknown_keys = set(data.keys()) - _VALID_TOP_LEVEL_KEYS
        if unknown_keys:
            raise ExternalContextError(
                f"Unknown keys in context file: {', '.join(sorted(unknown_keys))}. "
                f"Valid keys: {', '.join(sorted(_VALID_TOP_LEVEL_KEYS))}"
            )

        ctx = cls()
        _parse_organization(data.get("organization"), ctx)
        _parse_principals(data.get("principals"), ctx)
        _parse_resources(data.get("resources"), ctx)
        _parse_vpc_map(data.get("vpc_map"), ctx)
        _parse_management_account_id(data.get("management_account_id"), ctx)
        return ctx

    def lookup_principal_tags(self, principal_arn: str | None) -> dict[str, str] | None:
        """Find tags for a principal ARN using pattern matching.

        Exact matches take priority. If no exact match, wildcard patterns
        are tried (longest pattern first for specificity).

        Args:
            principal_arn: The principal ARN from the CloudTrail event.

        Returns:
            Merged tag dict if matched, None if no match.
        """
        if not principal_arn or not self.principal_tags:
            return None
        return _lookup_tags(principal_arn, self.principal_tags)

    def lookup_resource_tags(self, resource_arn: str | None) -> dict[str, str] | None:
        """Find tags for a resource ARN using pattern matching.

        Exact matches take priority. If no exact match, wildcard patterns
        are tried (longest pattern first for specificity).

        Args:
            resource_arn: The resource ARN extracted from the CloudTrail event.

        Returns:
            Merged tag dict if matched, None if no match.
        """
        if not resource_arn or not self.resource_tags:
            return None
        return _lookup_tags(resource_arn, self.resource_tags)

    def lookup_vpc_id(self, vpce_id: str | None) -> str | None:
        """Resolve a VPC endpoint ID to a VPC ID.

        Args:
            vpce_id: The VPC endpoint ID from the CloudTrail event.

        Returns:
            VPC ID if found in the mapping, None otherwise.
        """
        if not vpce_id or not self.vpc_map:
            return None
        return self.vpc_map.get(vpce_id)


def _lookup_tags(
    arn: str, tag_map: dict[str, dict[str, str]]
) -> dict[str, str] | None:
    """Look up tags for an ARN against a pattern-keyed tag map.

    Strategy: exact match first, then wildcard patterns sorted by
    length descending (most specific first). All matching patterns
    are merged, with more-specific patterns overriding less-specific.
    """
    # Exact match
    if arn in tag_map:
        return dict(tag_map[arn])

    # Collect wildcard matches sorted by specificity (longest first)
    matches: list[tuple[int, dict[str, str]]] = []
    for pattern, tags in tag_map.items():
        if "*" in pattern or "?" in pattern:
            if fnmatch.fnmatch(arn, pattern):
                matches.append((len(pattern), tags))

    if not matches:
        return None

    # Merge: least specific first, most specific last (overrides)
    matches.sort(key=lambda x: x[0])
    merged: dict[str, str] = {}
    for _, tags in matches:
        merged.update(tags)
    return merged


def _parse_organization(org: Any, ctx: ExternalContext) -> None:
    """Parse the 'organization' section of the context file."""
    if org is None:
        return
    if not isinstance(org, dict):
        raise ExternalContextError("'organization' must be an object")

    org_id = org.get("id")
    if org_id is not None:
        if not isinstance(org_id, str):
            raise ExternalContextError("'organization.id' must be a string")
        ctx.org_id = org_id

    paths = org.get("paths")
    if paths is not None:
        if not isinstance(paths, list):
            raise ExternalContextError("'organization.paths' must be an array")
        ctx.org_paths = [str(p) for p in paths]


def _parse_principals(principals: Any, ctx: ExternalContext) -> None:
    """Parse the 'principals' section of the context file."""
    if principals is None:
        return
    if not isinstance(principals, dict):
        raise ExternalContextError("'principals' must be an object")

    for arn_pattern, pdata in principals.items():
        if not isinstance(pdata, dict):
            raise ExternalContextError(
                f"Principal entry '{arn_pattern}' must be an object"
            )
        tags = pdata.get("tags")
        if tags is not None:
            if not isinstance(tags, dict):
                raise ExternalContextError(
                    f"Tags for principal '{arn_pattern}' must be an object"
                )
            ctx.principal_tags[arn_pattern] = {str(k): str(v) for k, v in tags.items()}


def _parse_resources(resources: Any, ctx: ExternalContext) -> None:
    """Parse the 'resources' section of the context file."""
    if resources is None:
        return
    if not isinstance(resources, dict):
        raise ExternalContextError("'resources' must be an object")

    for arn_pattern, rdata in resources.items():
        if not isinstance(rdata, dict):
            raise ExternalContextError(
                f"Resource entry '{arn_pattern}' must be an object"
            )
        tags = rdata.get("tags")
        if tags is not None:
            if not isinstance(tags, dict):
                raise ExternalContextError(
                    f"Tags for resource '{arn_pattern}' must be an object"
                )
            ctx.resource_tags[arn_pattern] = {str(k): str(v) for k, v in tags.items()}


def _parse_vpc_map(vpc_map: Any, ctx: ExternalContext) -> None:
    """Parse the 'vpc_map' section of the context file."""
    if vpc_map is None:
        return
    if not isinstance(vpc_map, dict):
        raise ExternalContextError("'vpc_map' must be an object")
    ctx.vpc_map = {str(k): str(v) for k, v in vpc_map.items()}


def _parse_management_account_id(value: Any, ctx: ExternalContext) -> None:
    """Parse the 'management_account_id' field of the context file."""
    if value is None:
        return
    if not isinstance(value, str):
        raise ExternalContextError("'management_account_id' must be a string")
    if not value.isdigit() or len(value) != 12:
        raise ExternalContextError(
            f"'management_account_id' must be a 12-digit AWS account ID, got: {value}"
        )
    ctx.management_account_id = value
