"""Central evaluation engine — the heart of PyAuthz.

The engine evaluates authorization requests against stored policies and roles.
It is thread-safe, async-friendly, and produces structured Decision objects
for every evaluation.
"""

from __future__ import annotations

import asyncio
import time
from datetime import UTC, datetime
from typing import Any

from pyauthz.audit.models import MutationAction, MutationEvent
from pyauthz.core.cache import DecisionCache
from pyauthz.core.conditions import evaluate_conditions
from pyauthz.core.decision import Decision
from pyauthz.core.errors import ConfigError, Denied
from pyauthz.filtering.compiler import FilterResult, compile_filter, compile_filter_auto_default
from pyauthz.models.action import Effect
from pyauthz.models.context import AccessRequest
from pyauthz.models.permission import Permission
from pyauthz.models.policy import Policy
from pyauthz.models.resource import Resource
from pyauthz.models.role import Role
from pyauthz.models.subject import Subject
from pyauthz.policies.combiners import COMBINERS
from pyauthz.storage.base import StorageBackend
from pyauthz.storage.memory import MemoryStorage


class PyAuthz:
    """The authorization engine.

    Evaluates (Subject, Action, Resource) tuples against policies and roles.
    Thread-safe for use in WSGI workers and async tasks.

    Example (3-line quickstart):
        rp = PyAuthz()
        rp.add_role(Role(name="admin", permissions=["*:*"]))
        rp.is_allowed(Subject(id="alice", roles=["admin"]), "read", Resource(id="doc-1", type="documents"))
        # True

    Example (full API):
        rp = PyAuthz(default_effect=Effect.DENY, combiner="deny-override")
        rp.add_role(Role(name="editor", permissions=["documents:*"]))
        rp.add_policy(Policy(name="deny-pii", effect=Effect.DENY, roles=["editor"], permissions=["pii:*"]))
        decision = rp.authorize(user, "documents:write", doc)
    """

    def __init__(
        self,
        *,
        default_effect: Effect = Effect.DENY,
        combiner: str = "deny-override",
        storage: StorageBackend | None = None,
    ) -> None:
        if combiner not in COMBINERS:
            raise ConfigError(f"Unknown combiner '{combiner}', available: {sorted(COMBINERS)}")
        self._default_effect = default_effect
        self._combiner_name = combiner
        self._combiner = COMBINERS[combiner]
        self._storage = storage or MemoryStorage()
        self._audit_logger: Any = None
        self._default_environment: dict[str, Any] = {}
        self._cache: DecisionCache | None = None

    # --- Mutation audit helper ---

    def _log_mutation(self, action: MutationAction, target_name: str, **details: Any) -> None:
        """Emit a mutation audit event if an audit logger is attached."""
        if self._audit_logger is None:
            return
        event = MutationEvent(
            timestamp=datetime.now(UTC),
            mutation_action=action,
            target_name=target_name,
            details=details,
        )
        self._audit_logger.log_mutation(event)

    # --- Role management ---

    def add_role(self, role: Role) -> None:
        """Register a role with the engine.

        Validates that adding this role does not create a cycle in the role
        hierarchy. Cycle detection happens here at load time, never at
        evaluation time.

        Args:
            role: The Role to add.

        Raises:
            ConfigError: If a role with the same name already exists, or if
                adding this role would create a cycle in the role hierarchy.
        """
        if role.parents:
            self._check_role_cycle(role)
        self._storage.add_role(role)
        if self._cache:
            self._cache.invalidate()
        self._log_mutation(
            MutationAction.ROLE_ADDED,
            role.name,
            permissions=role.permissions,
            parents=role.parents,
        )

    def remove_role(self, name: str) -> None:
        """Remove a role by name.

        Raises:
            ConfigError: If the role is not found.
        """
        self._storage.remove_role(name)
        if self._cache:
            self._cache.invalidate()
        self._log_mutation(MutationAction.ROLE_REMOVED, name)

    def replace_roles(self, roles: list[Role]) -> None:
        """Atomically replace all roles. Used for hot-reload.

        Validates that the new role set has no cycles before replacing.

        Raises:
            ConfigError: If the new role set contains a cycle.
        """
        role_map = {r.name: r for r in roles}
        for role in roles:
            if role.parents:
                self._check_cycle_in_map(role.name, role_map)
        self._storage.replace_roles(roles)
        if self._cache:
            self._cache.invalidate()
        self._log_mutation(
            MutationAction.ROLES_REPLACED,
            "*",
            role_names=[r.name for r in roles],
        )

    def _check_role_cycle(self, new_role: Role) -> None:
        """Check that adding new_role doesn't create a cycle in the hierarchy."""
        existing_roles = self._storage.get_roles()
        role_map = {**existing_roles, new_role.name: new_role}
        self._check_cycle_in_map(new_role.name, role_map)

    @staticmethod
    def _check_cycle_in_map(role_name: str, role_map: dict[str, Role]) -> None:
        """DFS cycle detection starting from role_name in role_map.

        Uses recursive DFS with a path set to distinguish diamond inheritance
        (visiting a node via two different paths, which is fine) from actual
        cycles (visiting a node that is an ancestor on the current path).
        """
        path: set[str] = set()
        visited: set[str] = set()

        def dfs(name: str) -> None:
            if name in path:
                raise ConfigError(f"Cycle detected in role hierarchy involving '{role_name}'")
            if name in visited:
                return
            visited.add(name)
            path.add(name)
            role = role_map.get(name)
            if role:
                for parent in role.parents:
                    dfs(parent)
            path.discard(name)

        dfs(role_name)

    # --- Policy management ---

    def add_policy(self, policy: Policy) -> None:
        """Add an explicit policy.

        Args:
            policy: The Policy to add.

        Raises:
            ConfigError: If a policy with the same name already exists.
        """
        self._storage.add_policy(policy)
        if self._cache:
            self._cache.invalidate()
        self._log_mutation(
            MutationAction.POLICY_ADDED,
            policy.name,
            effect=policy.effect.value,
            permissions=policy.permissions,
            roles=policy.roles,
        )

    def remove_policy(self, name: str) -> None:
        """Remove a policy by name.

        Raises:
            ConfigError: If the policy is not found.
        """
        self._storage.remove_policy(name)
        if self._cache:
            self._cache.invalidate()
        self._log_mutation(MutationAction.POLICY_REMOVED, name)

    def replace_policies(self, policies: list[Policy]) -> None:
        """Atomically replace all policies. Used for hot-reload."""
        self._storage.replace_policies(policies)
        if self._cache:
            self._cache.invalidate()
        self._log_mutation(
            MutationAction.POLICIES_REPLACED,
            "*",
            policy_names=[p.name for p in policies],
        )

    # --- Environment ---

    def set_environment(self, **kwargs: Any) -> None:
        """Set default environment values for all evaluations.

        These are merged with per-request environment kwargs, with per-request
        values taking precedence.

        Example:
            rp.set_environment(region="us-east-1", classification_level="UNCLASSIFIED")
        """
        self._default_environment.update(kwargs)

    # --- Query filtering ---

    def authorized_filter(self, subject: Subject, action: str, **env: Any) -> FilterResult:
        """Produce a filter expression for "what can this subject access?"

        Instead of checking each resource individually, this compiles applicable
        policies into a FilterExpression AST that can be translated to SQL
        WHERE clauses, Django Q objects, or raw SQL.

        Args:
            subject: The entity requesting access.
            action: The action string, e.g. "documents:read".
            **env: Environment context (currently not used in filter compilation
                but reserved for future use).

        Returns:
            FilterResult with the expression AST and any unpushable conditions.

        Example:
            filter_result = rp.authorized_filter(user, "documents:read")
            from pyauthz.filtering.sql import to_sqlalchemy_filter
            query = session.query(Document).filter(
                to_sqlalchemy_filter(filter_result.expression, Document)
            )
        """
        policies = self._storage.get_policies()
        roles = self._storage.get_roles()
        effective_roles = self._resolve_effective_roles(subject.roles, roles)

        # Resolve action shorthand
        # For authorized_filter, if no colon and no resource to infer from,
        # treat it as-is (the caller should provide fully-qualified actions)
        resolved_action = action

        if not policies:
            return compile_filter_auto_default(subject, resolved_action, roles, effective_roles)

        return compile_filter(
            subject=subject,
            action=resolved_action,
            policies=policies,
            roles=roles,
            effective_roles=effective_roles,
            combiner=self._combiner_name,
        )

    # --- Audit ---

    def set_cache(self, cache: DecisionCache) -> None:
        """Attach a decision cache to the engine.

        When a cache is set, authorization decisions are cached by
        (subject, action, resource) key. The cache is automatically
        invalidated when policies or roles change.

        Args:
            cache: A DecisionCache instance.

        Example:
            from pyauthz.core.cache import DecisionCache
            rp = PyAuthz()
            rp.set_cache(DecisionCache(ttl_seconds=30, max_size=5000))
        """
        self._cache = cache

    def set_audit_logger(self, logger: Any) -> None:
        """Attach an audit logger to the engine.

        Every authorize() call will emit an audit event through this logger.
        """
        self._audit_logger = logger

    # --- YAML loading ---

    def load_policies(self, path: str) -> None:
        """Load roles and policies from a YAML file or directory.

        Validates schema version and structure. Raises SchemaError,
        PolicyVersionError, or ConfigError on invalid input.

        Args:
            path: Path to a YAML file or directory of YAML files.
        """
        from pyauthz.storage.yaml_file import load_yaml_policies

        roles, policies = load_yaml_policies(path)
        for role in roles:
            self.add_role(role)
        for policy in policies:
            self._storage.add_policy(policy)

    # --- Authorization (sync) ---

    def authorize(self, subject: Subject, action: str, resource: Resource, **env: Any) -> Decision:
        """Check if subject is authorized to perform action on resource.

        Returns Decision on allow. Raises Denied (with Decision) on deny.

        Args:
            subject: The entity requesting access.
            action: The action string, e.g. "read" or "documents:read".
                If no colon, auto-expands using resource.type.
            resource: The resource being accessed.
            **env: Environment context (hour, ip, tenant_id, etc.).

        Returns:
            Decision object with full reasoning chain.

        Raises:
            Denied: If access is denied. The exception carries the Decision.

        Example:
            decision = rp.authorize(user, "documents:write", doc)
            print(decision.explain())

        Example (handling denial):
            try:
                rp.authorize(user, "pii:read", record)
            except Denied as e:
                print(e.decision.reasoning)
        """
        decision = self._evaluate(subject, action, resource, env)
        if self._audit_logger is not None:
            self._audit_logger.log(decision)
        if not decision.allowed:
            from pyauthz.core.explain import explain_denial

            suggestions = explain_denial(self, decision)
            raise Denied(decision, suggestions=suggestions)
        return decision

    def is_allowed(self, subject: Subject, action: str, resource: Resource, **env: Any) -> bool:
        """Check if subject is authorized. Returns bool, never raises.

        Args:
            subject: The entity requesting access.
            action: The action string.
            resource: The resource being accessed.
            **env: Environment context.

        Returns:
            True if allowed, False if denied.

        Example:
            if rp.is_allowed(user, "write", doc):
                # do the thing
                ...
        """
        decision = self._evaluate(subject, action, resource, env)
        if self._audit_logger is not None:
            self._audit_logger.log(decision)
        return decision.allowed

    def authorize_bulk(
        self,
        requests: list[tuple[Subject, str, Resource]],
        **env: Any,
    ) -> list[Decision]:
        """Evaluate multiple authorization requests at once.

        Returns a list of Decision objects (one per request). Does NOT raise
        Denied — check each decision's .allowed field instead.

        Args:
            requests: List of (subject, action, resource) tuples.
            **env: Shared environment context for all requests.

        Returns:
            List of Decision objects in the same order as requests.
        """
        decisions = []
        for subject, action, resource in requests:
            decision = self._evaluate(subject, action, resource, env)
            if self._audit_logger is not None:
                self._audit_logger.log(decision)
            decisions.append(decision)
        return decisions

    # --- Authorization (async) ---

    async def aauthorize(
        self, subject: Subject, action: str, resource: Resource, **env: Any
    ) -> Decision:
        """Async version of authorize(). Returns Decision on allow, raises Denied on deny."""
        decision = await asyncio.to_thread(self._evaluate, subject, action, resource, env)
        if self._audit_logger is not None:
            self._audit_logger.log(decision)
        if not decision.allowed:
            from pyauthz.core.explain import explain_denial

            suggestions = explain_denial(self, decision)
            raise Denied(decision, suggestions=suggestions)
        return decision

    async def ais_allowed(
        self, subject: Subject, action: str, resource: Resource, **env: Any
    ) -> bool:
        """Async version of is_allowed(). Returns bool, never raises."""
        decision = await asyncio.to_thread(self._evaluate, subject, action, resource, env)
        if self._audit_logger is not None:
            self._audit_logger.log(decision)
        return decision.allowed

    async def aauthorize_bulk(
        self,
        requests: list[tuple[Subject, str, Resource]],
        **env: Any,
    ) -> list[Decision]:
        """Async version of authorize_bulk()."""
        return await asyncio.to_thread(self.authorize_bulk, requests, **env)

    # --- Core evaluation logic ---

    def _evaluate(
        self,
        subject: Subject,
        action: str,
        resource: Resource,
        env: dict[str, Any],
    ) -> Decision:
        """Evaluate a single authorization request.

        This is the core evaluation path. It resolves the action string,
        finds matching policies, runs the combiner, and builds a Decision.
        """
        # Check cache first
        cache_key: str | None = None
        if self._cache is not None:
            cache_key = DecisionCache.make_key(
                subject.id, subject.roles, action, resource.id, resource.type
            )
            cached = self._cache.get(cache_key)
            if cached is not None:
                return cached

        start_us = time.monotonic_ns() // 1000

        # Resolve action shorthand: "write" -> "documents:write"
        resolved_action = self._resolve_action(action, resource)

        # Parse into resource_type and action_verb
        if ":" in resolved_action:
            req_resource_type, req_action_verb = resolved_action.split(":", 1)
        else:
            req_resource_type = resource.type
            req_action_verb = resolved_action

        # Merge environment
        merged_env = {**self._default_environment, **env}

        # Build the access request
        request = AccessRequest(
            subject=subject,
            action=resolved_action,
            resource=resource,
            environment=merged_env,
        )

        # Get policies and roles from storage
        policies = self._storage.get_policies()
        roles = self._storage.get_roles()

        # Resolve the subject's effective roles (including inherited via hierarchy)
        effective_roles = self._resolve_effective_roles(subject.roles, roles)

        # If no explicit policies exist, use the auto-default behavior:
        # allow if the subject's role has a matching permission
        if not policies:
            decision = self._auto_default_evaluate(
                request, roles, effective_roles, req_resource_type, req_action_verb, start_us
            )
            if self._cache is not None and cache_key is not None:
                self._cache.put(cache_key, decision)
            return decision

        # Find all applicable policies
        applicable = self._find_applicable_policies(
            policies, subject, roles, effective_roles, req_resource_type, req_action_verb, request
        )

        # Run the combiner
        if applicable:
            effect, matched_names, reasoning = self._combiner(applicable)
        else:
            effect = self._default_effect
            matched_names = []
            reasoning = ["no matching policy; applying default effect"]

        allowed = effect == Effect.ALLOW
        duration_us = (time.monotonic_ns() // 1000) - start_us

        decision = Decision(
            allowed=allowed,
            effect=effect,
            request=request,
            matched_policies=matched_names,
            reasoning=reasoning,
            duration_us=duration_us,
        )

        # Store in cache
        if self._cache is not None and cache_key is not None:
            self._cache.put(cache_key, decision)

        return decision

    def _resolve_action(self, action: str, resource: Resource) -> str:
        """Resolve action shorthand.

        If the action contains no colon, prepend the resource type:
        "write" + resource.type="documents" -> "documents:write"
        """
        if ":" not in action:
            return f"{resource.type}:{action}"
        return action

    @staticmethod
    def _resolve_effective_roles(subject_roles: list[str], roles: dict[str, Role]) -> set[str]:
        """Resolve all effective roles for a subject, including inherited parents.

        Walks the role hierarchy to collect all roles the subject has,
        directly or through parent inheritance.
        """
        effective: set[str] = set()
        stack = list(subject_roles)

        while stack:
            role_name = stack.pop()
            if role_name in effective:
                continue
            effective.add(role_name)
            role = roles.get(role_name)
            if role and role.parents:
                stack.extend(role.parents)

        return effective

    def _auto_default_evaluate(
        self,
        request: AccessRequest,
        roles: dict[str, Role],
        effective_roles: set[str],
        req_resource_type: str,
        req_action_verb: str,
        start_us: int,
    ) -> Decision:
        """Auto-default policy: allow if the subject's role has the matching permission.

        When no explicit policies are defined, this provides simple RBAC behavior.
        It checks each of the subject's effective roles (including inherited)
        for a matching permission string.
        """
        for role_name in effective_roles:
            role = roles.get(role_name)
            if role is None:
                continue
            for perm_str in role.permissions:
                try:
                    perm = Permission.from_string(perm_str)
                except ValueError:
                    continue
                if perm.matches(req_resource_type, req_action_verb):
                    duration_us = (time.monotonic_ns() // 1000) - start_us
                    return Decision(
                        allowed=True,
                        effect=Effect.ALLOW,
                        request=request,
                        matched_policies=["__auto_default__"],
                        reasoning=[
                            f"Auto-default: role '{role_name}' has permission "
                            f"'{perm_str}' matching '{req_resource_type}:{req_action_verb}'"
                        ],
                        duration_us=duration_us,
                    )

        duration_us = (time.monotonic_ns() // 1000) - start_us
        return Decision(
            allowed=self._default_effect == Effect.ALLOW,
            effect=self._default_effect,
            request=request,
            matched_policies=[],
            reasoning=["no matching permission in any role; applying default effect"],
            duration_us=duration_us,
        )

    def _find_applicable_policies(
        self,
        policies: list[Policy],
        subject: Subject,
        roles: dict[str, Role],
        effective_roles: set[str],
        req_resource_type: str,
        req_action_verb: str,
        request: AccessRequest,
    ) -> list[Policy]:
        """Find all policies that apply to this request.

        A policy applies when:
        1. It is enabled.
        2. It has no required roles OR the subject has at least one required role
           (after resolving role hierarchy).
        3. The requested action matches at least one of the policy's permissions.
        4. All conditions (if any) evaluate to True against the request context.
        """
        applicable: list[Policy] = []

        for policy in policies:
            if not policy.enabled:
                continue

            # Check role membership (using effective roles, which include inherited)
            if policy.roles and not any(r in effective_roles for r in policy.roles):
                continue

            # Check permission match
            if policy.permissions:
                matched = False
                for perm_str in policy.permissions:
                    try:
                        perm = Permission.from_string(perm_str)
                    except ValueError:
                        continue
                    if perm.matches(req_resource_type, req_action_verb):
                        matched = True
                        break
                if not matched:
                    continue
            # If no permissions specified on policy, it matches all actions

            # Evaluate conditions (ABAC)
            if policy.conditions:
                passed, _reasons = evaluate_conditions(policy.conditions, request)
                if not passed:
                    continue

            applicable.append(policy)

        return applicable
