"""Six-layer terminal whitelist + audit log helpers.

Whitelist evaluation priority (highest first):
    1. Blocklist (admin-managed, always rejects)
    2. Built-in safe whitelist (``_SAFE_COMMAND_PREFIXES`` from shell.py)
    3. Global whitelist (admin-managed, DB-backed)
    4. User whitelist (per-user, stored in DB, cached in-memory)
    5. Session whitelist (per-session, in-memory only)
    6. Danger detection (``_is_dangerous`` — requires confirmation)

Security note: shell operators (&&, ;, |, $(), backticks) are ALWAYS
checked before any whitelist match returns SAFE. A whitelisted prefix
like ``git push`` will NOT auto-execute ``git push && rm -rf /`` —
the shell operator triggers danger detection regardless of whitelist
membership.

This module is import-safe: it does not open any DB connection at import
time. All DB access is via explicit ``db_path`` arguments.
"""

from __future__ import annotations

import logging
import os
import shlex
import uuid
from collections.abc import Callable
from pathlib import Path

import aiosqlite

from agentkit.tools.shell import (
    _DANGEROUS_BINARY_FLAGS,
    _SAFE_COMMAND_PREFIXES,
)
from agentkit.server.auth.models import _now_iso

logger = logging.getLogger(__name__)


# ── Shell operator detection ─────────────────────────────────────────

# Shell chain/substitution operators that bypass whitelist prefix matching.
# If ANY of these appear in a command, the command is treated as dangerous
# regardless of whitelist membership — the whitelist pattern only matches
# the prefix, not the chained payload.
_SHELL_OPERATORS = ("&&", "||", ";", "|", "$(", "`", ">", "<", "\n")


def _has_shell_operators(command: str) -> bool:
    """Return True if the command contains shell chain/substitution operators.

    These operators allow chaining arbitrary commands behind a whitelisted
    prefix (e.g. ``git push && rm -rf /``). When present, the command must
    go through danger detection even if the prefix matches a whitelist.
    """
    return any(op in command for op in _SHELL_OPERATORS)


# ── Decision constants ────────────────────────────────────────────────

DECISION_EXECUTED = "executed"  # Ran without confirmation (whitelisted)
DECISION_CONFIRMED = "confirmed"  # Ran after user confirmation
DECISION_REJECTED = "rejected"  # User rejected confirmation prompt
DECISION_BLOCKED = "blocked"  # Blocked by blocklist
DECISION_DENIED = "denied"  # Blocked by safety check (non-whitelist)


# ── Pattern matching ─────────────────────────────────────────────────


def _extract_binary(command: str) -> str | None:
    """Extract the binary name (basename) from a command string.

    Returns ``None`` if the command cannot be parsed.
    """
    try:
        tokens = shlex.split(command.strip())
        if not tokens:
            return None
        return os.path.basename(tokens[0]).lower()
    except ValueError:
        return None


def _matches_pattern(command: str, pattern: str) -> bool:
    """Check if a command matches a whitelist/blocklist pattern.

    A pattern matches if:
      - The pattern is a single word and equals the command's binary name.
      - The pattern is a multi-word phrase and the command starts with it.

    Matching is case-insensitive.
    """
    cmd_lower = command.strip().lower()
    pattern_lower = pattern.strip().lower()
    if not pattern_lower:
        return False

    if " " in pattern_lower:
        return cmd_lower.startswith(pattern_lower)

    binary = _extract_binary(command)
    if binary is None:
        return False
    return binary == pattern_lower


def _matches_any_pattern(command: str, patterns: list[str]) -> tuple[bool, str | None]:
    """Return (matched, matched_pattern) for the first matching pattern."""
    for pattern in patterns:
        if _matches_pattern(command, pattern):
            return True, pattern
    return False, None


# ── Built-in whitelist check ──────────────────────────────────────────


def _matches_builtin_whitelist(command: str) -> bool:
    """Check if a command matches the built-in safe whitelist.

    Mirrors the logic in :func:`terminal._is_single_command_dangerous` —
    a command is built-in safe if its binary (or full prefix) appears in
    ``_SAFE_COMMAND_PREFIXES``.
    """
    cmd_lower = command.strip().lower()
    if not cmd_lower:
        return False

    binary = _extract_binary(command)
    if binary is None:
        return False

    for prefix in _SAFE_COMMAND_PREFIXES:
        prefix_lower = prefix.lower().strip()
        if " " in prefix_lower:
            if cmd_lower.startswith(prefix_lower):
                return True
        else:
            if binary == prefix_lower:
                return True
    return False


# ── DB-backed whitelist / blocklist lookups ───────────────────────────


async def _fetch_user_whitelist(db_path: str | Path, user_id: str) -> list[str]:
    """Fetch a user's whitelist command patterns from the DB."""
    patterns: list[str] = []
    try:
        async with aiosqlite.connect(str(db_path)) as db:
            cursor = await db.execute(
                "SELECT command_pattern FROM terminal_whitelist_user "
                "WHERE user_id = ? AND scope = 'user'",
                (user_id,),
            )
            rows = await cursor.fetchall()
            patterns = [row[0] for row in rows]
    except Exception as e:
        logger.warning(f"Failed to fetch user whitelist for {user_id}: {e}")
    return patterns


async def _fetch_global_whitelist(db_path: str | Path) -> list[str]:
    """Fetch the global (admin-managed) whitelist command patterns."""
    patterns: list[str] = []
    try:
        async with aiosqlite.connect(str(db_path)) as db:
            cursor = await db.execute(
                "SELECT command_pattern FROM terminal_whitelist_user WHERE scope = 'global'"
            )
            rows = await cursor.fetchall()
            patterns = [row[0] for row in rows]
    except Exception as e:
        logger.warning(f"Failed to fetch global whitelist: {e}")
    return patterns


async def _fetch_blocklist(db_path: str | Path) -> list[tuple[str, str | None]]:
    """Fetch the global blocklist (pattern, reason) pairs from the DB."""
    patterns: list[tuple[str, str | None]] = []
    try:
        async with aiosqlite.connect(str(db_path)) as db:
            cursor = await db.execute("SELECT command_pattern, reason FROM terminal_blocklist")
            rows = await cursor.fetchall()
            patterns = [(row[0], row[1]) for row in rows]
    except Exception as e:
        logger.warning(f"Failed to fetch blocklist: {e}")
    return patterns


# ── Audit log writer ──────────────────────────────────────────────────


async def write_audit_log(
    db_path: str | Path,
    *,
    user_id: str | None,
    username: str | None,
    session_id: str,
    command: str,
    decision: str,
    reason: str | None = None,
    cwd: str | None = None,
    exit_code: int | None = None,
    terminal_mode: str = "local",
) -> None:
    """Write a terminal audit log entry.

    Failures are logged but never raised — auditing must not break the
    terminal flow.
    """
    try:
        async with aiosqlite.connect(str(db_path)) as db:
            await db.execute(
                """INSERT INTO terminal_audit_logs
                   (id, user_id, username, session_id, command, decision,
                    reason, cwd, exit_code, terminal_mode, created_at)
                   VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
                (
                    str(uuid.uuid4()),
                    user_id,
                    username,
                    session_id,
                    command[:4096],
                    decision,
                    reason,
                    cwd,
                    exit_code,
                    terminal_mode,
                    _now_iso(),
                ),
            )
            await db.commit()
    except Exception as e:
        logger.warning(f"Failed to write terminal audit log: {e}")


# ── Top-level safety check ───────────────────────────────────────────


class SafetyDecision:
    """Result of a six-layer whitelist safety check."""

    __slots__ = ("safe", "decision", "reason", "matched_layer")

    def __init__(
        self,
        *,
        safe: bool,
        decision: str,
        reason: str | None = None,
        matched_layer: str | None = None,
    ) -> None:
        self.safe = safe
        self.decision = decision
        self.reason = reason
        self.matched_layer = matched_layer

    def __repr__(self) -> str:
        return (
            f"SafetyDecision(safe={self.safe}, decision={self.decision!r}, "
            f"matched_layer={self.matched_layer!r})"
        )


async def check_command_safety_v2(
    command: str,
    *,
    session_id: str,
    session_whitelist: set[str],
    user_id: str | None,
    db_path: str | Path | None,
    is_dangerous_fn: Callable[[str], bool] | None = None,
) -> SafetyDecision:
    """Evaluate a command against the six-layer whitelist.

    Args:
        command: The raw command string.
        session_id: Current terminal session ID (for logging).
        session_whitelist: In-memory per-session whitelist (binary names
            or command prefixes). Mutated by the caller after confirmation.
        user_id: The authenticated user's ID (``None`` in dev mode).
        db_path: Path to the auth DB. If ``None``, DB-backed layers are
            skipped (dev mode without DB).
        is_dangerous_fn: Override for the danger-detection function.
            Defaults to :func:`terminal._is_dangerous`.

    Returns:
        A :class:`SafetyDecision` describing whether the command may
        execute and which layer matched.
    """
    if is_dangerous_fn is None:
        from agentkit.server.routes.terminal import _is_dangerous

        is_dangerous_fn = _is_dangerous

    command_stripped = command.strip()
    if not command_stripped:
        return SafetyDecision(
            safe=False,
            decision=DECISION_DENIED,
            reason="空命令",
            matched_layer=None,
        )

    # Security: detect shell operators early. Commands with shell operators
    # (&&, ;, |, $(), etc.) can chain arbitrary payloads behind a whitelisted
    # prefix. Such commands must ALWAYS go through danger detection, even if
    # the prefix matches a whitelist entry.
    has_shell_ops = _has_shell_operators(command_stripped)

    # Layer 1: Blocklist (admin-managed, always rejects)
    if db_path is not None:
        blocklist = await _fetch_blocklist(db_path)
        for pattern, reason in blocklist:
            if _matches_pattern(command_stripped, pattern):
                return SafetyDecision(
                    safe=False,
                    decision=DECISION_BLOCKED,
                    reason=reason or f"命令匹配黑名单规则: {pattern}",
                    matched_layer="blocklist",
                )

    # Layer 2: Built-in safe whitelist
    # Shell operators bypass ALL whitelist layers — even built-in safe
    # commands like "ls" cannot chain "ls && rm -rf /" without confirmation.
    if not has_shell_ops and _matches_builtin_whitelist(command_stripped):
        return SafetyDecision(
            safe=True,
            decision=DECISION_EXECUTED,
            matched_layer="builtin",
        )

    # Layer 3: Global whitelist (admin-managed, DB-backed)
    if not has_shell_ops and db_path is not None:
        global_patterns = await _fetch_global_whitelist(db_path)
        matched, _ = _matches_any_pattern(command_stripped, global_patterns)
        if matched:
            return SafetyDecision(
                safe=True,
                decision=DECISION_EXECUTED,
                matched_layer="global_whitelist",
            )

    # Layer 4: User whitelist (DB-backed)
    if not has_shell_ops and db_path is not None and user_id is not None:
        user_patterns = await _fetch_user_whitelist(db_path, user_id)
        matched, _ = _matches_any_pattern(command_stripped, user_patterns)
        if matched:
            return SafetyDecision(
                safe=True,
                decision=DECISION_EXECUTED,
                matched_layer="user_whitelist",
            )

    # Layer 5: Session whitelist (in-memory)
    if not has_shell_ops:
        for wl_entry in session_whitelist:
            if _matches_pattern(command_stripped, wl_entry):
                return SafetyDecision(
                    safe=True,
                    decision=DECISION_EXECUTED,
                    matched_layer="session_whitelist",
                )

    # Layer 6: Danger detection
    if not is_dangerous_fn(command_stripped):
        return SafetyDecision(
            safe=True,
            decision=DECISION_EXECUTED,
            matched_layer="danger_check_passed",
        )

    # Not whitelisted and dangerous — require confirmation (local) or
    # approval (server terminal).
    return SafetyDecision(
        safe=False,
        decision=DECISION_DENIED,
        reason="此命令被识别为潜在危险操作，需要用户确认",
        matched_layer=None,
    )


# ── Whitelist mutation helpers ────────────────────────────────────────


def compute_whitelist_entry(command: str) -> str | None:
    """Compute the whitelist entry to add for a confirmed command.

    For binaries with dangerous-flag variants (e.g. ``rm -rf``), we return
    ``None`` — dangerous binaries must ALWAYS require confirmation and are
    never added to the session whitelist. For other binaries, we add just
    the binary name so subsequent calls with the same binary auto-execute.

    Returns ``None`` if the command cannot be parsed or contains shell
    operators (such commands should never be whitelisted).
    """
    try:
        tokens = shlex.split(command.strip())
        if not tokens:
            return None
        # Never whitelist commands with shell operators — they can chain
        # arbitrary payloads and must always require confirmation.
        if _has_shell_operators(command.strip()):
            return None
        binary = os.path.basename(tokens[0]).lower()
        if binary in _DANGEROUS_BINARY_FLAGS:
            # Dangerous binaries always require confirmation — do NOT
            # add to session whitelist.
            return None
        return binary
    except ValueError:
        return None
