"""Allowlist Management.

Functions for managing the exec approval allowlist.
Handles loading, saving, and modifying allowlist entries.
"""

from __future__ import annotations

import hashlib
import json
import logging
import os
import re
import time
import uuid
from pathlib import Path

from elizaos_plugin_shell.approvals.types import (
    EXEC_APPROVAL_DEFAULTS,
    ExecAllowlistEntry,
    ExecApprovalsAgent,
    ExecApprovalsDefaults,
    ExecApprovalsFile,
    ExecApprovalsResolved,
    ExecApprovalsSnapshot,
    ExecAsk,
    ExecSecurity,
    CommandResolution,
)

logger = logging.getLogger(__name__)

DEFAULT_SOCKET_PATH = "~/.eliza/exec-approvals.sock"
DEFAULT_FILE_PATH = "~/.eliza/exec-approvals.json"
DEFAULT_AGENT_ID = "default"


def expand_home(value: str) -> str:
    """Expand home directory in path."""
    if not value:
        return value
    return os.path.expanduser(value)


def _hash_content(raw: str | None) -> str:
    """Hash raw content for change detection."""
    data = (raw or "").encode("utf-8")
    return hashlib.sha256(data).hexdigest()


def get_approval_file_path() -> str:
    """Get the default approval file path."""
    return expand_home(DEFAULT_FILE_PATH)


def get_approval_socket_path() -> str:
    """Get the default socket path."""
    return expand_home(DEFAULT_SOCKET_PATH)


def _ensure_dir(file_path: str) -> None:
    """Ensure directory exists for a file path."""
    directory = os.path.dirname(file_path)
    os.makedirs(directory, exist_ok=True)


def _normalize_pattern(value: str | None) -> str | None:
    """Normalize allowlist pattern for comparison."""
    trimmed = (value or "").strip()
    return trimmed.lower() if trimmed else None


def _ensure_allowlist_ids(
    allowlist: list[ExecAllowlistEntry] | None,
) -> list[ExecAllowlistEntry] | None:
    """Ensure all allowlist entries have IDs."""
    if not allowlist:
        return allowlist

    changed = False
    result = []
    for entry in allowlist:
        if entry.id:
            result.append(entry)
        else:
            changed = True
            result.append(entry.model_copy(update={"id": str(uuid.uuid4())}))

    return result if changed else allowlist


def _merge_legacy_agent(
    current: ExecApprovalsAgent,
    legacy: ExecApprovalsAgent,
) -> ExecApprovalsAgent:
    """Merge legacy agent configuration with current."""
    allowlist: list[ExecAllowlistEntry] = []
    seen: set[str] = set()

    def push_entry(entry: ExecAllowlistEntry) -> None:
        key = _normalize_pattern(entry.pattern)
        if not key or key in seen:
            return
        seen.add(key)
        allowlist.append(entry)

    for entry in current.allowlist or []:
        push_entry(entry)
    for entry in legacy.allowlist or []:
        push_entry(entry)

    return ExecApprovalsAgent(
        security=current.security or legacy.security,
        ask=current.ask or legacy.ask,
        ask_fallback=current.ask_fallback or legacy.ask_fallback,
        auto_allow_skills=current.auto_allow_skills if current.auto_allow_skills is not None else legacy.auto_allow_skills,
        allowlist=allowlist if allowlist else None,
    )


def normalize_approvals(file: ExecApprovalsFile) -> ExecApprovalsFile:
    """Normalize approval configuration file."""
    socket_path = (file.socket or {}).get("path", "")
    socket_path = (socket_path or "").strip()
    token = (file.socket or {}).get("token", "")
    token = (token or "").strip()
    agents = dict(file.agents or {})

    # Handle legacy "default" agent
    legacy_default = agents.pop("default", None)
    if legacy_default is not None:
        main = agents.get(DEFAULT_AGENT_ID)
        if main:
            agents[DEFAULT_AGENT_ID] = _merge_legacy_agent(main, legacy_default)
        else:
            agents[DEFAULT_AGENT_ID] = legacy_default

    # Ensure all allowlist entries have IDs
    for key, agent in list(agents.items()):
        updated_allowlist = _ensure_allowlist_ids(agent.allowlist)
        if updated_allowlist is not agent.allowlist:
            agents[key] = agent.model_copy(update={"allowlist": updated_allowlist})

    defaults = file.defaults or ExecApprovalsDefaults()

    return ExecApprovalsFile(
        version=1,
        socket={
            "path": socket_path if socket_path else None,
            "token": token if token else None,
        },
        defaults=ExecApprovalsDefaults(
            security=defaults.security,
            ask=defaults.ask,
            ask_fallback=defaults.ask_fallback,
            auto_allow_skills=defaults.auto_allow_skills,
        ),
        agents=agents,
    )


def _generate_token() -> str:
    """Generate a secure token."""
    return os.urandom(24).hex()


def read_approvals_snapshot() -> ExecApprovalsSnapshot:
    """Read approval configuration snapshot."""
    file_path = get_approval_file_path()

    if not os.path.exists(file_path):
        file = normalize_approvals(ExecApprovalsFile(version=1, agents={}))
        return ExecApprovalsSnapshot(
            path=file_path,
            exists=False,
            raw=None,
            file=file,
            hash=_hash_content(None),
        )

    raw = Path(file_path).read_text(encoding="utf-8")
    parsed: ExecApprovalsFile | None = None

    try:
        data = json.loads(raw)
        parsed = ExecApprovalsFile(**data)
    except Exception as e:
        logger.warning(
            "Failed to parse approval config snapshot - file may be corrupted: %s",
            e,
        )

    if parsed and parsed.version == 1:
        file = normalize_approvals(parsed)
    else:
        file = normalize_approvals(ExecApprovalsFile(version=1, agents={}))
        if parsed and parsed.version != 1:
            logger.warning(
                "Approval config snapshot has unexpected version: %s",
                parsed.version,
            )

    return ExecApprovalsSnapshot(
        path=file_path,
        exists=True,
        raw=raw,
        file=file,
        hash=_hash_content(raw),
    )


def load_approvals() -> ExecApprovalsFile:
    """Load approval configuration."""
    file_path = get_approval_file_path()

    try:
        if not os.path.exists(file_path):
            logger.debug("Approval config file does not exist, using defaults")
            return normalize_approvals(ExecApprovalsFile(version=1, agents={}))

        raw = Path(file_path).read_text(encoding="utf-8")
        try:
            data = json.loads(raw)
            parsed = ExecApprovalsFile(**data)
        except Exception as e:
            logger.error(
                "Failed to parse approval config JSON - file may be corrupted: %s", e
            )
            return normalize_approvals(ExecApprovalsFile(version=1, agents={}))

        if parsed.version != 1:
            logger.warning("Approval config has unexpected version: %s", parsed.version)
            return normalize_approvals(ExecApprovalsFile(version=1, agents={}))

        return normalize_approvals(parsed)
    except OSError as e:
        logger.error("Failed to load approval config: %s", e)
        return normalize_approvals(ExecApprovalsFile(version=1, agents={}))


def save_approvals(file: ExecApprovalsFile) -> None:
    """Save approval configuration.

    Raises:
        OSError: If file cannot be written.
    """
    file_path = get_approval_file_path()
    try:
        _ensure_dir(file_path)
        content = json.dumps(file.model_dump(mode="json"), indent=2) + "\n"
        Path(file_path).write_text(content, encoding="utf-8")
        try:
            os.chmod(file_path, 0o600)
        except OSError:
            pass  # Best-effort on platforms without chmod
    except Exception as e:
        logger.error("Failed to save approval configuration: %s", e)
        raise OSError(f"Failed to save approval configuration to {file_path}: {e}") from e


def ensure_approvals() -> ExecApprovalsFile:
    """Ensure approval configuration exists with socket/token.

    Raises:
        OSError: If configuration cannot be saved.
    """
    loaded = load_approvals()
    normalized = normalize_approvals(loaded)

    socket_path = ((normalized.socket or {}).get("path") or "").strip()
    token = ((normalized.socket or {}).get("token") or "").strip()

    updated = normalized.model_copy(
        update={
            "socket": {
                "path": socket_path if socket_path else get_approval_socket_path(),
                "token": token if token else _generate_token(),
            }
        }
    )

    try:
        save_approvals(updated)
    except OSError as e:
        logger.warning(
            "Failed to save approval config during ensure_approvals: %s", e
        )
        raise

    return updated


def _normalize_security(
    value: ExecSecurity | None, fallback: ExecSecurity
) -> ExecSecurity:
    """Normalize security value."""
    if value in (ExecSecurity.ALLOWLIST, ExecSecurity.FULL, ExecSecurity.DENY):
        return value  # type: ignore[return-value]
    return fallback


def _normalize_ask(value: ExecAsk | None, fallback: ExecAsk) -> ExecAsk:
    """Normalize ask value."""
    if value in (ExecAsk.ALWAYS, ExecAsk.OFF, ExecAsk.ON_MISS):
        return value  # type: ignore[return-value]
    return fallback


def resolve_approvals(
    agent_id: str | None = None,
    overrides: ExecApprovalsDefaults | None = None,
) -> ExecApprovalsResolved:
    """Resolve approval configuration for an agent."""
    try:
        file = ensure_approvals()
    except OSError:
        logger.warning("Could not ensure approval config exists - using read-only config")
        file = load_approvals()

    return resolve_approvals_from_file(
        file=file,
        agent_id=agent_id,
        overrides=overrides,
        path=get_approval_file_path(),
        socket_path=expand_home((file.socket or {}).get("path") or get_approval_socket_path()),
        token=(file.socket or {}).get("token") or "",
    )


def resolve_approvals_from_file(
    file: ExecApprovalsFile,
    agent_id: str | None = None,
    overrides: ExecApprovalsDefaults | None = None,
    path: str | None = None,
    socket_path: str | None = None,
    token: str | None = None,
) -> ExecApprovalsResolved:
    """Resolve approval configuration from a loaded file."""
    file = normalize_approvals(file)
    defaults = file.defaults or ExecApprovalsDefaults()
    agent_key = agent_id or DEFAULT_AGENT_ID
    agents = file.agents or {}
    agent = agents.get(agent_key, ExecApprovalsAgent())
    wildcard = agents.get("*", ExecApprovalsAgent())

    overrides = overrides or ExecApprovalsDefaults()
    assert EXEC_APPROVAL_DEFAULTS.security is not None
    assert EXEC_APPROVAL_DEFAULTS.ask is not None
    assert EXEC_APPROVAL_DEFAULTS.ask_fallback is not None
    assert EXEC_APPROVAL_DEFAULTS.auto_allow_skills is not None

    fb_security = overrides.security or EXEC_APPROVAL_DEFAULTS.security
    fb_ask = overrides.ask or EXEC_APPROVAL_DEFAULTS.ask
    fb_ask_fallback = overrides.ask_fallback or EXEC_APPROVAL_DEFAULTS.ask_fallback
    fb_auto_allow = overrides.auto_allow_skills if overrides.auto_allow_skills is not None else EXEC_APPROVAL_DEFAULTS.auto_allow_skills

    resolved_defaults = ExecApprovalsDefaults(
        security=_normalize_security(defaults.security, fb_security),
        ask=_normalize_ask(defaults.ask, fb_ask),
        ask_fallback=_normalize_security(
            defaults.ask_fallback or fb_ask_fallback, fb_ask_fallback
        ),
        auto_allow_skills=bool(
            defaults.auto_allow_skills if defaults.auto_allow_skills is not None else fb_auto_allow
        ),
    )

    assert resolved_defaults.security is not None
    assert resolved_defaults.ask is not None
    assert resolved_defaults.ask_fallback is not None

    resolved_agent = ExecApprovalsDefaults(
        security=_normalize_security(
            agent.security or wildcard.security or resolved_defaults.security,
            resolved_defaults.security,
        ),
        ask=_normalize_ask(
            agent.ask or wildcard.ask or resolved_defaults.ask,
            resolved_defaults.ask,
        ),
        ask_fallback=_normalize_security(
            agent.ask_fallback or wildcard.ask_fallback or resolved_defaults.ask_fallback,
            resolved_defaults.ask_fallback,
        ),
        auto_allow_skills=bool(
            agent.auto_allow_skills
            if agent.auto_allow_skills is not None
            else (
                wildcard.auto_allow_skills
                if wildcard.auto_allow_skills is not None
                else resolved_defaults.auto_allow_skills
            )
        ),
    )

    allowlist = list(wildcard.allowlist or []) + list(agent.allowlist or [])

    return ExecApprovalsResolved(
        path=path or get_approval_file_path(),
        socket_path=expand_home(
            socket_path or (file.socket or {}).get("path") or get_approval_socket_path()
        ),
        token=token or (file.socket or {}).get("token") or "",
        defaults=resolved_defaults,
        agent=resolved_agent,
        allowlist=allowlist,
        file=file,
    )


def _glob_to_regex(pattern: str) -> re.Pattern[str]:
    """Convert glob pattern to regex."""
    regex = "^"
    i = 0
    while i < len(pattern):
        ch = pattern[i]
        if ch == "*":
            if i + 1 < len(pattern) and pattern[i + 1] == "*":
                regex += ".*"
                i += 2
                continue
            regex += "[^/]*"
            i += 1
            continue
        if ch == "?":
            regex += "."
            i += 1
            continue
        regex += re.escape(ch)
        i += 1
    regex += "$"
    return re.compile(regex, re.IGNORECASE)


def _normalize_match_target(value: str) -> str:
    """Normalize path for matching."""
    return value.replace("\\", "/").lower()


def _try_realpath(value: str) -> str | None:
    """Try to get realpath (resolve symlinks)."""
    try:
        return os.path.realpath(value)
    except OSError:
        return None


def _matches_pattern(pattern: str, target: str) -> bool:
    """Check if pattern matches target."""
    trimmed = pattern.strip()
    if not trimmed:
        return False

    expanded = expand_home(trimmed) if trimmed.startswith("~") else trimmed
    normalized_pattern = _normalize_match_target(expanded)
    normalized_target = _normalize_match_target(target)

    regex = _glob_to_regex(normalized_pattern)
    return bool(regex.search(normalized_target))


def match_allowlist(
    entries: list[ExecAllowlistEntry],
    resolution: CommandResolution | None,
) -> ExecAllowlistEntry | None:
    """Match command against allowlist."""
    if not entries or not resolution or not resolution.resolved_path:
        return None

    resolved_path = resolution.resolved_path

    for entry in entries:
        pattern = (entry.pattern or "").strip()
        if not pattern:
            continue

        has_path = "/" in pattern or "\\" in pattern or "~" in pattern
        if not has_path:
            continue

        if _matches_pattern(pattern, resolved_path):
            return entry

    return None


def record_allowlist_use(
    approvals: ExecApprovalsFile,
    agent_id: str | None,
    entry: ExecAllowlistEntry,
    command: str,
    resolved_path: str | None = None,
) -> bool:
    """Record allowlist usage.

    Returns:
        True if successful, False if save failed.
    """
    target = agent_id or DEFAULT_AGENT_ID
    agents = approvals.agents or {}
    existing = agents.get(target, ExecApprovalsAgent())
    allowlist = list(existing.allowlist or [])

    next_allowlist = []
    for item in allowlist:
        if item.pattern == entry.pattern:
            next_allowlist.append(
                item.model_copy(
                    update={
                        "id": item.id or str(uuid.uuid4()),
                        "last_used_at": time.time() * 1000,
                        "last_used_command": command,
                        "last_resolved_path": resolved_path,
                    }
                )
            )
        else:
            next_allowlist.append(item)

    agents[target] = existing.model_copy(update={"allowlist": next_allowlist})
    approvals_updated = approvals.model_copy(update={"agents": agents})

    try:
        save_approvals(approvals_updated)
        return True
    except OSError as e:
        logger.warning("Failed to record allowlist usage: %s", e)
        return False


def add_allowlist_entry(
    approvals: ExecApprovalsFile,
    agent_id: str | None,
    pattern: str,
) -> bool:
    """Add a new allowlist entry.

    Returns:
        True if entry was added, False if already exists or save failed.
    """
    trimmed = pattern.strip()
    if not trimmed:
        logger.warning("Attempted to add empty pattern to allowlist")
        return False

    target = agent_id or DEFAULT_AGENT_ID
    agents = dict(approvals.agents or {})
    existing = agents.get(target, ExecApprovalsAgent())
    allowlist = list(existing.allowlist or [])

    if any(e.pattern == trimmed for e in allowlist):
        logger.debug("Pattern already in allowlist: %s", trimmed)
        return False

    allowlist.append(
        ExecAllowlistEntry(
            id=str(uuid.uuid4()),
            pattern=trimmed,
            last_used_at=time.time() * 1000,
        )
    )

    agents[target] = existing.model_copy(update={"allowlist": allowlist})
    approvals_updated = approvals.model_copy(update={"agents": agents})

    try:
        save_approvals(approvals_updated)
        logger.info("Added pattern to allowlist: %s (agent=%s)", trimmed, target)
        return True
    except OSError as e:
        logger.error("Failed to save allowlist after adding entry: %s", e)
        return False


_SECURITY_ORDER: dict[ExecSecurity, int] = {
    ExecSecurity.DENY: 0,
    ExecSecurity.ALLOWLIST: 1,
    ExecSecurity.FULL: 2,
}

_ASK_ORDER: dict[ExecAsk, int] = {
    ExecAsk.OFF: 0,
    ExecAsk.ON_MISS: 1,
    ExecAsk.ALWAYS: 2,
}


def min_security(a: ExecSecurity, b: ExecSecurity) -> ExecSecurity:
    """Get minimum security level."""
    return a if _SECURITY_ORDER[a] <= _SECURITY_ORDER[b] else b


def max_ask(a: ExecAsk, b: ExecAsk) -> ExecAsk:
    """Get maximum ask level."""
    return a if _ASK_ORDER[a] >= _ASK_ORDER[b] else b
