"""Vault file browser — read-only filesystem browsing rooted at `root_dir`.

Powers the Sidebar's Files mode. Two endpoints:

- ``GET /api/vault/browse?path=`` — list immediate children of a directory.
- ``GET /api/vault/browse/read?path=`` — stream a file for preview.

Security posture:

* Read-only. No write/delete/rename endpoints here.
* Every requested path is resolved against ``root_dir`` and must stay inside
  it — this blocks both ``..`` traversal and symlink escape (we compare the
  *resolved* path, which follows symlinks, against the resolved root).
* Preview reads cap text at 8 MiB; anything larger OR any binary MIME is
  returned as ``{binary: true}`` so the UI falls through to the existing
  ``/api/wiki/open-file`` OS-handler.
* Dotfiles / dotdirs are listed but the top-level listing hides the
  ``.git`` folder (noisy) — users can still navigate in explicitly.
"""

from __future__ import annotations

import logging
import mimetypes
from datetime import UTC, datetime
from pathlib import Path

from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel

log = logging.getLogger(__name__)

router = APIRouter(prefix="/vault/browse", tags=["vault-browse"])

# 8 MiB preview cap for text reads. Larger files fall through to OS-open.
_MAX_TEXT_PREVIEW_BYTES = 8 * 1024 * 1024

# MIME prefixes we'll inline-preview as text. Everything else = binary.
# These are IANA-registered technical identifiers, not content policy — the
# same extension means the same thing in Swedish, Japanese, or Swahili. Not
# strategy, so they stay in code.
_TEXT_MIME_PREFIXES = ("text/",)  # arch:deterministic — IANA MIME prefix, language-independent
_TEXT_MIME_EXACT = frozenset({  # arch:deterministic — IANA-registered MIME types, language-independent technical identifiers
    "application/json",
    "application/yaml",
    "application/x-yaml",
    "application/xml",
    "application/javascript",
    "application/x-javascript",
    "application/x-toml",
    "application/x-python",
})
# Known-text extensions (mimetypes sometimes misses these).
_TEXT_EXTENSIONS = frozenset({  # arch:deterministic — file-format technical identifiers (not strategy)
    ".md", ".markdown", ".txt", ".log", ".csv", ".tsv",
    ".json", ".jsonl", ".ndjson", ".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf",
    ".py", ".js", ".ts", ".tsx", ".jsx", ".vue", ".svelte",
    ".css", ".scss", ".sass", ".less",
    ".html", ".htm", ".xml", ".svg",
    ".sh", ".bash", ".zsh", ".fish", ".ps1",
    ".rs", ".go", ".rb", ".php", ".java", ".c", ".h", ".cpp", ".hpp",
    ".sql", ".graphql", ".gql",
    ".env", ".gitignore", ".editorconfig", ".prettierrc",
    ".vtt", ".srt",
})
_IMAGE_EXTENSIONS = frozenset({  # arch:deterministic — image-format technical identifiers (not strategy)
    ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg", ".ico",
})

# Subdirectories hidden from top-level listings (user can navigate in if they
# really want — just not by default because they're noisy).
_TOP_LEVEL_HIDDEN = frozenset({".git"})


class BrowseEntry(BaseModel):
    name: str
    path: str          # logical path relative to root_dir (forward slashes)
    kind: str          # "dir" | "file"
    size: int          # bytes (0 for directories)
    modified: str      # ISO-8601 UTC
    is_hidden: bool    # starts with "."


class BrowseListing(BaseModel):
    root: str          # absolute resolved root_dir (for display)
    path: str          # logical path of the listed directory ("" = root)
    parent: str | None # parent's logical path (None at root)
    entries: list[BrowseEntry]


def _root_dir(request: Request) -> Path:
    """Return the resolved vault root. Raises 500 if settings aren't wired."""
    settings = getattr(request.app.state, "settings", None)
    if settings is None or not getattr(settings, "root_dir", None):
        raise HTTPException(status_code=500, detail="Vault root not configured")
    return Path(settings.root_dir).expanduser().resolve()


def _resolve_safe(root: Path, rel: str) -> Path:
    """Resolve ``rel`` against ``root`` and ensure the result stays inside.

    Blocks both ``..`` traversal (Path.resolve collapses it) and symlink
    escape (Path.resolve follows symlinks, so the comparison catches a
    symlink that points outside the root).
    """
    # Normalize: strip leading slashes, convert backslashes, drop empty segs.
    rel = (rel or "").replace("\\", "/").strip("/")
    candidate = (root / rel).resolve() if rel else root
    try:
        candidate.relative_to(root)
    except ValueError as exc:
        raise HTTPException(status_code=400, detail="Path escapes vault root") from exc
    return candidate


def _to_logical(root: Path, absolute: Path) -> str:
    """Convert absolute path back to forward-slash relative path."""
    try:
        rel = absolute.relative_to(root)
    except ValueError:
        return ""
    s = str(rel).replace("\\", "/")
    return "" if s == "." else s


def _iso_utc(ts: float) -> str:
    return datetime.fromtimestamp(ts, tz=UTC).isoformat()


def _is_probably_text(path: Path) -> bool:
    ext = path.suffix.lower()
    if ext in _TEXT_EXTENSIONS:
        return True
    if ext in _IMAGE_EXTENSIONS:
        return False
    mime, _ = mimetypes.guess_type(str(path))
    if not mime:
        # Unknown type: peek at first KB, treat as text if no NUL bytes.
        try:
            with path.open("rb") as f:
                chunk = f.read(4096)
            return b"\x00" not in chunk
        except OSError:
            return False
    if any(mime.startswith(p) for p in _TEXT_MIME_PREFIXES):
        return True
    return mime in _TEXT_MIME_EXACT


def _is_image(path: Path) -> bool:
    ext = path.suffix.lower()
    if ext in _IMAGE_EXTENSIONS:
        return True
    mime, _ = mimetypes.guess_type(str(path))
    return bool(mime and mime.startswith("image/"))


@router.get("")
async def list_directory(request: Request, path: str = "") -> BrowseListing:
    """List immediate children of a directory. Empty path = vault root."""
    root = _root_dir(request)
    target = _resolve_safe(root, path)

    if not target.exists():
        raise HTTPException(status_code=404, detail=f"Not found: {path or '/'}")
    if not target.is_dir():
        raise HTTPException(status_code=400, detail=f"Not a directory: {path or '/'}")

    logical = _to_logical(root, target)
    is_root = logical == ""
    parent: str | None = None
    if not is_root:
        parent = _to_logical(root, target.parent)
        # Normalize empty parent to "" (root), preserve None only at the root itself.

    entries: list[BrowseEntry] = []
    try:
        children = sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
    except PermissionError as exc:
        raise HTTPException(status_code=403, detail="Permission denied") from exc

    for child in children:
        name = child.name
        # Hide known-noisy dirs at top level only.
        if is_root and name in _TOP_LEVEL_HIDDEN:
            continue
        try:
            stat = child.stat()
            is_dir = child.is_dir()
        except OSError:
            # Broken symlinks etc. — skip silently.
            continue
        entries.append(
            BrowseEntry(
                name=name,
                path=_to_logical(root, child),
                kind="dir" if is_dir else "file",
                size=0 if is_dir else int(stat.st_size),
                modified=_iso_utc(stat.st_mtime),
                is_hidden=name.startswith("."),
            )
        )

    return BrowseListing(
        root=str(root),
        path=logical,
        parent=parent,
        entries=entries,
    )


@router.get("/read")
async def read_file(request: Request, path: str):
    """Return file contents for preview.

    Text files under 8 MiB return ``{text, encoding, size, modified}``.
    Images return the raw bytes via FileResponse (for inline ``<img>``).
    Everything else returns ``{binary: true, size, modified}`` — the UI
    should then fall back to ``POST /api/wiki/open-file`` for the OS handler.
    """
    root = _root_dir(request)
    target = _resolve_safe(root, path)

    if not target.exists():
        raise HTTPException(status_code=404, detail=f"Not found: {path}")
    if not target.is_file():
        raise HTTPException(status_code=400, detail=f"Not a file: {path}")

    try:
        stat = target.stat()
    except OSError as exc:
        raise HTTPException(status_code=500, detail="Could not stat file") from exc

    size = int(stat.st_size)
    modified = _iso_utc(stat.st_mtime)

    # Images: stream raw bytes with the right mime so <img> works.
    if _is_image(target):
        mime, _ = mimetypes.guess_type(str(target))
        return FileResponse(target, media_type=mime or "application/octet-stream")

    # Text preview: cap at 8 MiB, try utf-8 then latin-1 as a graceful fallback.
    if _is_probably_text(target) and size <= _MAX_TEXT_PREVIEW_BYTES:
        try:
            text = target.read_text(encoding="utf-8")
            encoding = "utf-8"
        except UnicodeDecodeError:
            try:
                text = target.read_text(encoding="latin-1")
                encoding = "latin-1"
            except OSError as exc:
                raise HTTPException(status_code=500, detail="Could not read file") from exc
        except OSError as exc:
            raise HTTPException(status_code=500, detail="Could not read file") from exc
        return JSONResponse({
            "text": text,
            "encoding": encoding,
            "size": size,
            "modified": modified,
            "path": _to_logical(root, target),
        })

    # Too big or binary: tell the UI to fall through to OS-open.
    return JSONResponse({
        "binary": True,
        "size": size,
        "modified": modified,
        "path": _to_logical(root, target),
        "reason": "too_large" if size > _MAX_TEXT_PREVIEW_BYTES else "binary",
    })
