# -*- coding: utf-8 -*-
"""Resource Governor — Policy evaluation + audit logging +
sandbox config compilation.

Core responsibilities: policy evaluation, audit recording, dynamic rule
addition, sandbox config compilation.
"""

from __future__ import annotations
import hashlib
import logging
from pathlib import Path
from typing import Optional

from .policy import (
    GovernancePolicy,
    GovernanceRule,
    GovernanceAction,
    GovernanceDecision,
    ToolCallSpec,
    DEFAULT_SANDBOX_DENY_PATHS,
    FILE_READ_TOOLS,
    FILE_WRITE_TOOLS,
    load_governance_policy,
    save_governance_policy,
    _parse_match,
)
from .audit import AuditLog
from ..constant import WORKING_DIR

from ..sandbox import (
    SandboxCapability,
    SandboxConfig,
    MountSpec,
    probe_sandbox_support,
    detect_platform_mode,
)

logger = logging.getLogger(__name__)


# Module-level debounce: avoid spamming the auto-disable warning on every
# tool-execution check.  0 = never warned; otherwise the epoch of the last
# warning.
_sandbox_admin_warned_at: float = 0.0


class ResourceGovernor:
    """ResourceGovernor — core of policy and audit.

    Responsibilities:
        1. Policy evaluation: assert_policy(tool_call) → GovernanceDecision
        2. Audit logging: audit(tool_call, decision) records an audit log entry
        3. Sandbox config compilation: compile_sandbox_config() → SandboxConfig
        4. Dynamic rule addition: add_rule(...) after user approval

    NOT responsible for (TBD):
        - sandbox creation/destruction → managed by orchestration layer
        - Runtime/Agent scheduling → TBD
    """

    def __init__(
        self,
        workspace_dir: str,
        governance_dir: Optional[str] = None,
        coding_project_dir: Optional[str] = None,
    ):
        self.workspace_dir = Path(workspace_dir)
        # Coding project dir (Coding Mode). Falls back to the workspace
        # when unset so the CODING_PROJECT_DIR policy placeholder always
        # resolves to a concrete path.
        self.coding_project_dir = Path(
            coding_project_dir or workspace_dir,
        )
        # Policy is stored outside the workspace to prevent agent tampering.
        # Use ``<basename>_<hash>`` so two workspaces with the same basename
        # but different absolute paths (e.g. ``/Users/a/project`` vs
        # ``/Users/b/project``) do not share the same policy directory.
        if governance_dir is not None:
            self._governance_dir = Path(governance_dir)
        else:
            self._governance_dir = WORKING_DIR / "governance"
        ws_resolved = str(self.workspace_dir.resolve())
        ws_hash = hashlib.sha256(
            ws_resolved.encode("utf-8"),
        ).hexdigest()[:12]
        self._policy_dir = (
            self._governance_dir / f"{self.workspace_dir.name}_{ws_hash}"
        )
        self._policy: Optional[GovernancePolicy] = None
        self._sandbox_available: bool = False
        self._sandbox_capability: Optional[SandboxCapability] = None

    # ------------------------------------------------------------------
    # Lifecycle (kept but not expanded, overlaps with runtime)
    # ------------------------------------------------------------------

    @property
    def sandbox_available(self) -> bool:
        """Whether the current platform supports sandbox.

        Readable after start().
        """
        return self._sandbox_available

    @property
    def sandbox_capability(self) -> Optional[SandboxCapability]:
        """Probe result from start() (SandboxCapability)."""
        return self._sandbox_capability

    @staticmethod
    def _sandbox_globally_enabled() -> bool:
        """Read the global ``security.sandbox_enabled`` switch (config.json).

        Uses the mtime-cached :func:`load_config`, so this is cheap on the
        hot path and automatically reflects Console updates (``save_config``
        invalidates the cache). Defaults to False (sandbox off). On a config
        read error it returns True (fail-safe): a glitch then routes the
        command through the sandbox instead of running it unsandboxed.

        On Windows, if ``sandbox_enabled`` is True but the process lacks
        administrator privileges, the switch is treated as False for this
        session and a warning is logged.  The config file is NOT modified
        so the user's intent is preserved for future admin launches.
        """
        global _sandbox_admin_warned_at
        try:
            from ..config import load_config

            config = load_config()
            enabled = bool(config.security.sandbox_enabled)

            # Runtime guard: if sandbox is enabled but we're on Windows
            # without admin, treat as disabled for this session.
            if enabled:
                from ..utils.platform import is_windows_admin

                if not is_windows_admin():
                    import time as _time

                    now = _time.monotonic()
                    # Throttle: this method is called on every tool-execution
                    # check.  Without a debounce interval the same warning
                    # would be emitted hundreds of times per session.
                    # 30 s keeps the user informed without flooding the log.
                    if now - _sandbox_admin_warned_at > 30:
                        logger.warning(
                            "Windows sandbox inactive for this session: "
                            "sandbox_enabled is true but the process lacks "
                            "administrator privileges. To use the sandbox, "
                            "restart QwenPaw as administrator.",
                        )
                        _sandbox_admin_warned_at = now
                    return False

            return enabled
        except Exception:
            logger.debug(
                "ResourceGovernor: failed to read sandbox_enabled; "
                "assuming enabled (fail-safe).",
                exc_info=True,
            )
            return True

    def _sandbox_usable(self) -> bool:
        """Effective sandbox availability: platform support AND global switch.

        When the operator turns the switch off, the sandbox is treated as
        if the platform did not support it — ``SANDBOX_FALLBACK`` then
        escalates to ASK rather than running the command unsandboxed.
        """
        return self._sandbox_available and self._sandbox_globally_enabled()

    @property
    def sandbox_usable(self) -> bool:
        """Whether sandbox execution is supported and globally enabled."""
        return self._sandbox_usable()

    def start(self) -> None:
        """Load policy and probe sandbox capabilities."""
        self._policy_dir.mkdir(parents=True, exist_ok=True)
        self._policy = load_governance_policy(
            str(self._policy_dir),
            str(self.workspace_dir),
            str(self.coding_project_dir),
        )

        # Persist the loaded policy back to disk.
        try:
            save_governance_policy(
                self._policy,
                str(self._policy_dir),
                str(self.workspace_dir),
                str(self.coding_project_dir),
            )
        except Exception:
            logger.exception(
                "ResourceGovernor.start: failed to persist policy.yaml",
            )

        self._sandbox_capability = probe_sandbox_support()
        self._sandbox_available = self._sandbox_capability.supported
        if not self._sandbox_available:
            logger.warning(
                "ResourceGovernor: sandbox not available — %s. "
                "SANDBOX_FALLBACK will escalate to ASK.",
                self._sandbox_capability.reason,
            )

    def stop(self) -> None:
        """Persist policy (if modified) and close the audit log."""
        if self._policy and self._policy.rules:
            try:
                save_governance_policy(
                    self._policy,
                    str(self._policy_dir),
                    str(self.workspace_dir),
                    str(self.coding_project_dir),
                )
            except Exception:
                logger.exception(
                    "ResourceGovernor.stop: failed to persist policy.yaml",
                )
        # Close the global AuditLog: triggers the deferred VACUUM and
        # releases the SQLite handle. Without this, audit.db is only
        # closed on interpreter exit (best-effort) which is fragile
        # under supervised restarts and may leak WAL frames.
        try:
            self.audit_log.close()
        except Exception:
            logger.exception(
                "ResourceGovernor.stop: failed to close AuditLog",
            )

    # ------------------------------------------------------------------
    # Core interface 1: Policy evaluation
    # ------------------------------------------------------------------

    def assert_policy(self, tc_spec: ToolCallSpec) -> GovernanceDecision:
        """Evaluate policy for a tool call.

        Flow:
            1. policy.evaluate(tc_spec) → GovernanceDecision
            2. Sandbox degradation: if SANDBOX_FALLBACK and sandbox
               unavailable → escalate to ASK
            3. If SANDBOX_FALLBACK → compile sandbox config and attach
            4. Log the governance decision (observability)
            5. Return decision (does NOT record audit)

        Returns GovernanceDecision:
            ALLOW            → explicit resource tool executes directly;
                               bash tool executes with sandbox
                               pre-authorization
            DENY             → rejected
            ASK              → ask user
            SANDBOX_FALLBACK → bash tool with no rule match, sandbox fallback
        """
        decision = self.policy.evaluate(tc_spec)

        # Sandbox not usable (platform unsupported OR the global
        # security.sandbox_enabled switch is off): a SANDBOX_FALLBACK cannot
        # run inside a sandbox. Reaching this point means the command already
        # cleared Phase 1 deep scan (CRITICAL → DENY), Phase 1.5 shell-danger
        # keywords, and every builtin/user DENY/ASK rule — i.e. nothing
        # flagged it. Rather than nag the user, run it unsandboxed (ALLOW).
        # Only the sandbox isolation layer is dropped; Phase 0-2 protections
        # stay fully in force. STRICT never reaches here (it returns ASK in
        # evaluate() before producing SANDBOX_FALLBACK).
        if (
            decision.action is GovernanceAction.SANDBOX_FALLBACK
            and not self._sandbox_usable()
        ):
            reason = (
                "sandbox disabled by config"
                if self._sandbox_available
                else f"sandbox unavailable ({self._sandbox_capability.reason})"
            )
            logger.info(
                "ResourceGovernor: %s, running '%s' unsandboxed (ALLOW)",
                reason,
                tc_spec.tool_name,
            )
            decision = GovernanceDecision(
                action=GovernanceAction.ALLOW,
                reason=f"{reason}, running unsandboxed",
            )

        # compile sandbox config
        if decision.action is GovernanceAction.SANDBOX_FALLBACK:
            decision.sandbox_config = self.compile_sandbox_config(tc_spec)

        # Observability: log every governance decision so operators can
        # trace policy evaluation results without querying audit.db.
        # ``target`` is truncated to keep log lines bounded.
        target_repr = (tc_spec.target or "")[:120]
        # sandbox backend actually used for this call: the compiled
        # config's mode (bubblewrap/landlock/...), or "-" when the
        # decision does not route through a sandbox.
        sandbox_mode = (
            decision.sandbox_config.mode.value
            if decision.sandbox_config is not None
            else "-"
        )
        logger.info(
            "governance decision: tool=%s target=%r action=%s source=%s "
            "sandbox=%s reason=%s",
            tc_spec.tool_name,
            target_repr,
            decision.action.value,
            decision.source,
            sandbox_mode,
            decision.reason,
        )
        return decision

    # ------------------------------------------------------------------
    # Core interface 2: Audit logging
    # ------------------------------------------------------------------

    def audit(
        self,
        tc_spec: ToolCallSpec,
        decision: GovernanceDecision,
    ) -> None:
        """Record a governance decision to the audit log.

        Callers should invoke this after ``assert_policy()`` to persist
        the decision for compliance / forensics:

            decision = governor.assert_policy(tc_spec)
            governor.audit(tc_spec, decision)
        """
        self.audit_log.record(
            str(self.workspace_dir),
            tc_spec,
            decision,
        )

    # ------------------------------------------------------------------
    # Core interface 3: Compile sandbox config
    # ------------------------------------------------------------------

    def compile_sandbox_config(  # pylint: disable=unused-argument
        self,
        tc_spec: ToolCallSpec,
    ) -> SandboxConfig:
        """Compile sandbox filesystem permission config based on policy.

        Sandbox security model:
            - Workspace is the working directory, always mounted
              readwrite (Bash needs it to work)
            - Paths from FILE_READ_TOOLS / FILE_WRITE_TOOLS in
              user_rules are compiled into mounts
            - deny_paths block sensitive paths (defense-in-depth)
            - Policy decisions control whether a command can execute;
              sandbox controls filesystem boundaries

        Mounts compilation logic:
            Iterate over user_rules, for each rule:
              - Parse match → (tool_name, pattern)
              - If tool_name ∈ FILE_READ_TOOLS → readonly mount
              - If tool_name ∈ FILE_WRITE_TOOLS → readwrite mount
            Same path uses the most permissive access (write > read).

        Returns SandboxConfig dataclass (from qwenpaw.sandbox.config).
        """
        ws = str(self.workspace_dir)

        # ── Compile mounts from user_rules ──
        # path → writable mapping: same path uses the most permissive access
        mount_map: dict[str, bool] = {}

        for rule in self.policy.user_rules:
            try:
                rule_tool, rule_pattern = _parse_match(rule.match)
            except (ValueError, IndexError):
                continue

            # Extract path from pattern: strip trailing * and other
            # wildcards to get directory prefix
            path = self._resolve_mount_path(rule_pattern, ws)
            if not path:
                continue

            if rule_tool in FILE_READ_TOOLS:
                # readonly mount, but keep write if already present
                if path not in mount_map:
                    mount_map[path] = False
            elif rule_tool in FILE_WRITE_TOOLS:
                # readwrite mount
                mount_map[path] = True

        mounts = [MountSpec(path=p, writable=w) for p, w in mount_map.items()]
        # Workspace is always readwrite
        mounts.insert(0, MountSpec(path=ws, writable=True))

        # Coding project dir is readwrite by default (Coding Mode). When
        # it is distinct from the workspace, mount it explicitly so Bash
        # can write there; the policy ALLOW rule alone is not enough for
        # sandboxed shell tools.
        cpd = str(self.coding_project_dir)
        if cpd and cpd != ws and not any(m.path == cpd for m in mounts):
            mounts.append(MountSpec(path=cpd, writable=True))

        return SandboxConfig(
            mode=detect_platform_mode(),
            workspace_dir=ws,
            mounts=mounts,
            deny_paths=list(DEFAULT_SANDBOX_DENY_PATHS),
            # NOTE: network_allow=["*"] grants full network access inside
            # the sandbox. This is intentional for now because:
            #   1. Many common commands need network (pip, git, npm, curl).
            #   2. Landlock network restriction requires ABI v4 (kernel 6.7+),
            #      which is not yet widely available in production.
            #   3. macOS Seatbelt can deny network but lacks domain-level
            #      filtering, making blanket denial too disruptive.
            # TODO: revisit default when Landlock ABI v4 is mainstream;
            #       consider making this configurable in policy.yaml.
            network_allow=["*"],
            timeout_seconds=60,
            env_vars={k: "" for k in self.policy.env_blacklist},
        )

    @staticmethod
    def _resolve_mount_path(pattern: str, workspace_dir: str) -> str:
        """Derive a mount path from a rule pattern.

        Strategy:
            - WORKSPACE_DIR/* → workspace_dir (mount as a whole)
            - /absolute/path/* → /absolute/path (take directory part)
            - relative path → workspace_dir / relative (take directory part)
            - Pure wildcards (*, **) → skip, cannot derive a concrete path
        """
        p = pattern.rstrip("*").rstrip("/")

        if not p or p == ".":
            return ""

        # WORKSPACE_DIR placeholder (defensive: should already be
        # replaced at load time)
        if "WORKSPACE_DIR" in p:
            p = p.replace("WORKSPACE_DIR", workspace_dir)

        # Absolute path
        if p.startswith("/"):
            return p

        # Relative path → resolve based on workspace
        return str(Path(workspace_dir) / p)

    # ------------------------------------------------------------------
    # Core interface 4: Dynamic rule addition
    # ------------------------------------------------------------------

    def add_rule(self, rule: GovernanceRule) -> None:
        """Dynamically append a rule to the policy after user approval.

        Approved rules carry a duration (session / permanent).
        The rule is also persisted to policy.yaml.
        Note: rules are only appended to user_rules; builtin_rules are
        immutable.
        """
        self.policy.add_rule(rule)
        if self._policy is not None:
            save_governance_policy(
                self._policy,
                str(self._policy_dir),
                str(self.workspace_dir),
                str(self.coding_project_dir),
            )

    async def add_approved_rule(
        self,
        tc_spec: ToolCallSpec,
        *,
        generalized_target: str,
    ) -> bool:
        """Add an ALLOW rule for a user-approved tool call.

        Args:
            generalized_target: the generalized target/pattern (e.g.
                ``"git *"``), already computed upstream by
                ``generalize_target_for_approval``.

        Returns True if a rule was actually added, False if skipped
        (e.g. builtin ask, empty target pattern).
        """
        if self.is_builtin_ask(tc_spec):
            return False

        try:
            if not generalized_target:
                logger.debug(
                    "ResourceGovernor: empty pattern, skipping rule "
                    "for tool=%s target=%s",
                    tc_spec.tool_name,
                    tc_spec.target,
                )
                return False

            match = f"{tc_spec.tool_name}({generalized_target})"
            rule = GovernanceRule(
                match=match,
                action=GovernanceAction.ALLOW,
                reason="user approved",
                grantee=tc_spec.agent_id or "*",
                duration="session",
                session_id=tc_spec.session_id,
            )
            self.add_rule(rule)
            logger.info(
                "ResourceGovernor: added approved rule: %s",
                rule.match,
            )
            return True
        except Exception:
            logger.debug(
                "ResourceGovernor: failed to persist approved rule",
                exc_info=True,
            )
            return False

    def is_builtin_ask(self, tc_spec: ToolCallSpec) -> bool:
        """Determine whether a tool call's ASK comes from builtin_rules.

        builtin ask → no rule recorded on approval (asks every time)
        user ask   → rule recorded on approval (won't ask next time)

        Called by tool_adapter's approval flow to decide whether to
        persist a new rule.
        """
        if not self._policy:
            return False
        source = self._policy.evaluate_source(tc_spec)
        return source == "builtin_rules"

    # ------------------------------------------------------------------
    # Property access
    # ------------------------------------------------------------------

    @property
    def policy(self) -> GovernancePolicy:
        if self._policy is None:
            raise RuntimeError("ResourceGovernor not started")
        return self._policy

    @property
    def audit_log(self) -> AuditLog:
        """Get the global AuditLog singleton."""
        return AuditLog.get_instance(
            db_dir=self._governance_dir,
        )
