"""
Centralized path safety and resolution for the Mugi project.
Use this module for all path expansion, workspace-relative resolution, and forbidden-path checks.
"""
import os
import re
from pathlib import Path

# Plugins directory — single source of truth (matches persistence.MUGI_PLUGINS_DIR).
# Defined here to avoid circular imports; persistence re-exports for convenience.
PLUGINS_DIR = os.path.abspath(os.path.expanduser("~/.mugi/plugins"))
DASHBOARD_PLUGINS_DIR = os.path.abspath(os.path.expanduser("~/.mugi/dashboard_plugins"))

# User-visible output directory: always use this for files the user should open (charts, images, saved results, etc.).
# Never direct users to ~/.mugi (hidden). Create ~/Documents/Mugi if missing. Exposed as FILE_OUTPUT_DIR in run_python_code.
USER_OUTPUT_DIR = os.path.abspath(os.path.expanduser(os.path.join("~", "Documents", "Mugi")))

# OCR output directory: OCR'd documents go here (not in ~/.mugi). Subdir of USER_OUTPUT_DIR.
OCR_OUTPUT_DIR = os.path.abspath(os.path.expanduser(os.path.join("~", "Documents", "Mugi", "OCR")))


def get_user_output_dir() -> str:
    """Return USER_OUTPUT_DIR and ensure it exists. Use for any file the user might need to open."""
    os.makedirs(USER_OUTPUT_DIR, exist_ok=True)
    return USER_OUTPUT_DIR


def get_ocr_output_dir() -> str:
    """Return OCR_OUTPUT_DIR (~/Documents/Mugi/OCR) and ensure it exists. Use for all OCR output files."""
    os.makedirs(OCR_OUTPUT_DIR, exist_ok=True)
    return OCR_OUTPUT_DIR


def normalize_path_spaces(requested_path: str) -> str:
    """Strip hallucinated whitespace around path-structural characters.

    LLM tokenizers commonly insert spaces around dots and slashes
    (e.g. `.mugi` -> `. mugi`, `HEARTBEAT.md` -> `HEARTBEAT. md`,
    `~/ .mugi` -> `~ / . mugi`).

    This collapses whitespace adjacent to '.', '/', and '~' while preserving
    intentional spaces inside path components (e.g. `My Project`).

    Also repairs:
    - Spaces injected into hex/UUID-like sequences (e.g. `8c 6173c2` -> `8c6173c2`)
    - Spaces before common file extensions (e.g. `file . md` -> `file.md`)
    """
    req = requested_path or ""
    if not req or not any(ch.isspace() for ch in req):
        return req
    normalized = re.sub(r"\s*([./~])\s*", r"\1", req)

    # Collapse spaces between hex characters in UUID-like sequences.
    # Pattern: hex char, whitespace, hex char — where at least one side is
    # adjacent to a dash (UUID section boundary) or the whole segment looks
    # like a hex run.  We iterate because a single pass may leave residual
    # spaces in long UUIDs that were split at multiple points.
    _HEX_SPACE_RE = re.compile(r"([0-9a-fA-F])\s+([0-9a-fA-F])")
    if _HEX_SPACE_RE.search(normalized):
        # Only apply to segments that look like hex/UUID (contain dashes
        # between hex groups or are purely hex+spaces).  Split on '/' so
        # we operate on individual path components.
        parts = normalized.split("/")
        for i, part in enumerate(parts):
            if _HEX_SPACE_RE.search(part) and _looks_like_hex_segment(part):
                parts[i] = _HEX_SPACE_RE.sub(r"\1\2", part)
        normalized = "/".join(parts)

    # Collapse spaces before common file extensions at the end of the path
    # or before a '/' (e.g. `file . md` already became `file.md` from the
    # structural-char pass, but `file .md` with no space after the dot is
    # caught here as well).
    normalized = re.sub(
        r"\s+\.([a-zA-Z0-9]{1,10})(?=/|$)",
        r".\1",
        normalized,
    )

    # Repair frequent LLM whitespace corruption specifically for Mugi temp uploads.
    # We intentionally keep this scoped to the temp_uploads tree to avoid
    # over-normalizing legitimate user paths that include spaces.
    lowered = normalized.lower()
    if ".mugi" in lowered and "temp" in lowered and "upload" in lowered:
        normalized = re.sub(r"/temp\s*_?\s*uploads/", "/temp_uploads/", normalized, flags=re.IGNORECASE)
        marker = ".mugi/temp_uploads/"
        lower_norm = normalized.lower()
        idx = lower_norm.find(marker)
        if idx != -1:
            tail_start = idx + len(marker)
            normalized = normalized[:tail_start] + normalized[tail_start:].replace(" ", "")
    return normalized


def _looks_like_hex_segment(segment: str) -> bool:
    """Return True if *segment* looks like a hex string or UUID with injected spaces.

    Matches patterns like ``8c 6173c2-a0d1-4fe0-86cf-650ef 822c 39e`` where
    the only non-hex characters are dashes, spaces, and dots (for extensions).
    """
    stripped = segment.strip()
    if not stripped:
        return False
    # Remove trailing extension (e.g. ".md") before checking hex content
    ext_match = re.search(r'\.[a-zA-Z0-9]{1,10}$', stripped)
    if ext_match:
        stripped = stripped[:ext_match.start()]
    # After removing spaces and dashes, the remainder should be mostly hex
    core = re.sub(r'[\s\-]', '', stripped)
    if not core:
        return False
    hex_chars = sum(1 for c in core if c in '0123456789abcdefABCDEF')
    return hex_chars / len(core) >= 0.8


def normalize_likely_mugi_temp_upload_path(requested_path: str) -> str:
    """Back-compat alias for older callers."""
    return normalize_path_spaces(requested_path)


# ─── Block lists (same semantics as original tools.py) ───

DANGEROUS_PREFIXES = [
    "/etc", "/bin", "/sbin", "/usr/bin", "/usr/sbin", "/usr/local/bin",
    "/System", "/Library", "/Applications", "/private", "/dev", "/proc", "/sys",
    "/var", "/var/log", "/var/db", "/var/root", "/var/mail", "/var/spool",
    "/opt", "/Network", "/Volumes",
    os.path.expanduser("~/.ssh"),
    os.path.expanduser("~/.aws"),
    os.path.expanduser("~/.config"),
    os.path.expanduser("~/.gnupg"),
    os.path.expanduser("~/.docker"),
    os.path.expanduser("~/.kube"),
    os.path.expanduser("~/.npm"),
    os.path.expanduser("~/.yarn"),
    os.path.expanduser("~/.cache"),
    os.path.expanduser("~/.local/share"),
    os.path.expanduser("~/.bash_history"),
    os.path.expanduser("~/.zsh_history"),
    os.path.expanduser("~/.python_history"),
    "/root", "/home/root",
    "/etc/passwd", "/etc/shadow", "/etc/sudoers", "/etc/sudoers.d",
    "/etc/group", "/etc/hosts", "/etc/resolv.conf",
    "/var/root", "/var/db/dslocal",
]

DANGEROUS_FILENAMES = [
    "passwd", "shadow", "sudoers", "sudoers.d", "id_rsa", "id_ed25519",
    "id_dsa", "authorized_keys", ".pem", ".key", ".crt", ".p12",
    ".aws/credentials", ".kube/config", ".docker/config.json",
    "keychain", "login.keychain", "Local Items.keychain",
]

# Overly broad roots for search/list operations (block ~, /, /Users, /Volumes)
DANGEROUS_ROOTS = [
    "/",
    "/Users",
    "/Volumes",
    os.path.expanduser("~"),
]
# Normalize for comparison (resolve symlinks where possible)
try:
    _home = os.path.expanduser("~")
    _home_real = str(Path(_home).resolve(strict=False))
    if _home_real != _home and _home_real not in DANGEROUS_ROOTS:
        DANGEROUS_ROOTS.append(_home_real)
except Exception:
    pass


def is_dangerous_path(path: str, _log_callback=None) -> bool:
    """
    Return True if the path is in a protected, sensitive, or system-critical location.
    _log_callback(message, level) is optional for tool-layer logging.
    """
    try:
        p = Path(path).expanduser().resolve(strict=False)
        path_str = str(p).rstrip(os.sep).lower()

        for prefix in DANGEROUS_PREFIXES:
            prefix_lower = prefix.lower().rstrip(os.sep)
            if path_str == prefix_lower or path_str.startswith(prefix_lower + os.sep):
                if _log_callback:
                    _log_callback(f"[SAFETY] Blocked path: {path} (matches prefix {prefix})", "WARN")
                return True

        path_parts = path_str.split(os.sep)
        for part in path_parts:
            for bad_file in DANGEROUS_FILENAMES:
                if bad_file in part:
                    if _log_callback:
                        _log_callback(f"[SAFETY] Blocked path: {path} (contains {bad_file})", "WARN")
                    return True

        if "keychain" in path_str or ".keychain" in path_str:
            if _log_callback:
                _log_callback(f"[SAFETY] Blocked path: {path} (keychain related)", "WARN")
            return True

        if len(path_str) > 1024:
            if _log_callback:
                _log_callback(f"[SAFETY] Blocked path: {path} (excessive length)", "WARN")
            return True

        return False
    except Exception as e:
        if _log_callback:
            _log_callback(f"[SAFETY] Path resolution failed for {path}: {e} → blocked", "ERROR")
        return True


def is_safe_search_root(path: str) -> bool:
    """Return False if the path is an obviously dangerous or overly broad search root."""
    try:
        p = Path(path).expanduser().resolve(strict=False)
        path_str = str(p).rstrip(os.sep)
        # Root "/" becomes "" after rstrip; reject it
        if not path_str or path_str == "/":
            return False
        for bad in DANGEROUS_ROOTS:
            bad_resolved = str(Path(bad).resolve(strict=False))
            if path_str == bad_resolved or path_str.startswith(bad_resolved + os.sep):
                return False
        return True
    except Exception:
        return False


def is_path_allowed(path: str, base_dir: str | None = None, allow_write: bool = False) -> bool:
    """
    Return True if path is allowed: not dangerous and, if base_dir is set, under base_dir.
    If base_dir is None, only dangerous-path check is applied.
    """
    try:
        resolved = str(Path(path).expanduser().resolve(strict=False))
        if is_dangerous_path(resolved):
            return False
        if base_dir:
            base_resolved = str(Path(base_dir).expanduser().resolve(strict=False))
            if not (resolved == base_resolved or resolved.startswith(base_resolved + os.sep)):
                return False
        return True
    except Exception:
        return False


def is_plugins_dir_write_blocked(path: str) -> bool:
    """Return True if *path* is inside the general or dashboard plugins directory.

    Used by write tools to prevent accidental or malicious writes to the
    plugins directory.  Only ``create_plugin`` and ``run_python_code``
    (when evolution mode is on) are allowed to bypass this check.

    Uses os.path.realpath here (only) to resolve symlinks — a plugin could
    include a symlink pointing outside its directory to escape hash verification.
    """
    if not path:
        return False
    try:
        # Use realpath specifically here to catch symlink escapes out of the plugins dir.
        resolved = os.path.realpath(os.path.expanduser(path))
        for root in (PLUGINS_DIR, DASHBOARD_PLUGINS_DIR):
            root_resolved = os.path.realpath(root)
            if resolved == root_resolved or resolved.startswith(root_resolved + os.sep):
                return True
        return False
    except Exception:
        return True


# ─── Agent tool layer block list ──────────────────────────────────────────────
# These paths are accessible by application code but must not be reachable by
# agent read_file/write_file tool calls. They are NOT in DANGEROUS_PREFIXES
# because the app itself legitimately writes to them (venv bootstrap, logging).
AGENT_TOOL_BLOCKED_PREFIXES: list[str] = [
    os.path.expanduser("~/.mugi/logs"),
    os.path.expanduser("~/.mugi/mesh"),
    os.path.expanduser("~/.mugi/venv"),
    os.path.expanduser("~/.mugi/ai_env"),
]

# App-owned state at ~/.mugi/<name> (not under workspace/). Agents must not read/write these
# via file tools — models sometimes try to "mark onboarding done" by overwriting mugi_config.toml.
_AGENT_TOOL_BLOCKED_MUGI_ROOT_FILES: frozenset[str] = frozenset(
    os.path.normpath(os.path.join(os.path.expanduser("~/.mugi"), name)).lower()
    for name in (
        "mugi_config.toml",
        "app_prefs.json",
        "chats.db",
        "search.db",
        "proactive_queue.jsonl",
        "agent.sock",
    )
)


def is_agent_tool_blocked(path: str) -> bool:
    """Return True if *path* is in a location that agent file tools must not access.

    This is checked at the tool layer (file_ops.py) only — not at the app level.
    """
    if not path:
        return False
    try:
        p = Path(path).expanduser().resolve(strict=False)
        path_str = str(p).rstrip(os.sep).lower()
        if path_str in _AGENT_TOOL_BLOCKED_MUGI_ROOT_FILES:
            return True
        for prefix in AGENT_TOOL_BLOCKED_PREFIXES:
            prefix_lower = prefix.lower().rstrip(os.sep)
            if path_str == prefix_lower or path_str.startswith(prefix_lower + os.sep):
                return True
        return False
    except Exception:
        return True


# YouTube / media titles often use U+FF5C (fullwidth vertical line); models usually type ASCII |.
_FULLWIDTH_VERTICAL_LINE = "\uFF5C"


def _is_path_lexically_under_root(path: str, root: str) -> bool:
    """True when *path* is under *root* by prefix, without resolving symlinks.

    Used to decide whether workspace containment applies before realpath — an
    absolute symlink inside the workspace must still be checked (DEF-087).
    """
    if not path or not root:
        return False
    p = os.path.abspath(os.path.normpath(path)).rstrip(os.sep)
    r = os.path.abspath(os.path.normpath(root)).rstrip(os.sep)
    return p == r or p.startswith(r + os.sep)


def _is_path_under_root(path: str, root: str) -> bool:
    """Return True when *path* equals *root* or is a direct descendant (symlinks resolved)."""
    if not path or not root:
        return False
    p = os.path.realpath(path).rstrip(os.sep)
    r = os.path.realpath(root).rstrip(os.sep)
    return p == r or p.startswith(r + os.sep)


def resolve_path_with_vertical_bar_variants(abs_path: str) -> str:
    """If *abs_path* is missing, try the same path with | <-> fullwidth vertical line in the basename.

    Returns an existing file path when a single-character confusable is the only difference;
    otherwise returns *abs_path* unchanged (caller still checks isfile).
    """
    if not abs_path or os.path.isfile(abs_path):
        return abs_path
    parent = os.path.dirname(abs_path)
    base = os.path.basename(abs_path)
    if not parent or not base or not os.path.isdir(parent):
        return abs_path
    alts: list[str] = []
    if "|" in base:
        alts.append(base.replace("|", _FULLWIDTH_VERTICAL_LINE))
    if _FULLWIDTH_VERTICAL_LINE in base:
        alts.append(base.replace(_FULLWIDTH_VERTICAL_LINE, "|"))
    for b in alts:
        candidate = os.path.join(parent, b)
        if os.path.isfile(candidate):
            return candidate
    return abs_path


def safe_resolve_path(
    workspace_path: str,
    requested_path: str,
    allow_write: bool = False,
    create_parents: bool = True,
) -> tuple[str | None, str]:
    """
    Resolve requested_path (relative to workspace if not absolute), expand ~, and check safety.
    Returns (resolved_absolute_path, error_message). If error_message is non-empty, path is None.
    If allow_write and create_parents, parent directories are created.
    """
    try:
        req = (requested_path or "").strip().strip(' "\n,')
        if not req:
            return None, "No path provided."
        expanded = os.path.expanduser(req)
        if not os.path.isabs(expanded):
            base = os.path.expanduser((workspace_path or "~/.mugi/workspace").strip())
            resolved = os.path.normpath(os.path.join(base, expanded))
        else:
            resolved = os.path.normpath(expanded)
        abs_path = os.path.abspath(resolved)

        if is_dangerous_path(abs_path):
            return None, f"Path is in a protected or dangerous location: {abs_path}"

        ws_base = os.path.expanduser((workspace_path or "~/.mugi/workspace").strip())
        ws_abs = os.path.abspath(os.path.normpath(ws_base))
        try:
            path_real = os.path.realpath(abs_path)
            ws_real = os.path.realpath(ws_abs)
        except OSError as exc:
            return None, f"Path resolution failed: {exc}"

        must_stay_in_workspace = (not os.path.isabs(expanded)) or _is_path_lexically_under_root(
            abs_path, ws_abs
        )
        if must_stay_in_workspace and not _is_path_under_root(path_real, ws_real):
            return None, f"Path escapes workspace: {path_real}"

        if allow_write and create_parents:
            parent = os.path.dirname(path_real)
            if parent and not os.path.exists(parent):
                os.makedirs(parent, exist_ok=True)
        return path_real, ""
    except Exception as e:
        return None, str(e)


def is_path_safe_for_user_add(path: str) -> tuple[bool, str]:
    """Check whether *path* is safe to add as a user-configured allowed directory.

    Returns (True, "") if safe, or (False, reason) if the path should be rejected.
    Rejects dangerous prefixes/roots and the plugins directory.
    """
    if not path or not path.strip():
        return False, "Empty path."
    try:
        resolved = str(Path(path).expanduser().resolve(strict=False))
    except Exception as e:
        return False, f"Cannot resolve path: {e}"
    if is_dangerous_path(resolved):
        return False, "This path is in a protected/dangerous location and cannot be allowed."
    plugins_resolved = str(Path(PLUGINS_DIR).resolve(strict=False))
    if resolved == plugins_resolved or resolved.startswith(plugins_resolved + os.sep):
        return False, "The plugins directory cannot be added as an allowed path."
    resolved_stripped = resolved.rstrip(os.sep)
    for bad in DANGEROUS_ROOTS:
        bad_resolved = str(Path(bad).resolve(strict=False)).rstrip(os.sep)
        if resolved_stripped == bad_resolved:
            return False, "This is an overly broad root path and cannot be allowed."
    return True, ""


def secure_write_file(path: str, content: str | bytes, mode: int = 0o600) -> None:
    """
    Write content to path and chmod to mode (default 0o600). Use for sensitive files
    (socket dir, queue file, plist). Creates parent directories if needed.
    """
    path = os.path.abspath(os.path.expanduser(path))
    try:
        from utils.user_model import enforce_low_level_user_model_write_guard

        err = enforce_low_level_user_model_write_guard(path, mode="overwrite")
        if err:
            raise PermissionError(err)
    except PermissionError:
        raise
    parent = os.path.dirname(path)
    if parent and not os.path.exists(parent):
        os.makedirs(parent, exist_ok=True)
    if isinstance(content, str):
        content = content.encode("utf-8")
    with open(path, "wb") as f:
        f.write(content)
    try:
        os.chmod(path, mode)
    except OSError:
        pass
