"""Session-scoped file storage.

Session directories live as long as the session itself exists in the database.
They are removed only when the session is explicitly deleted (DELETE /sessions/{id})
or when an orphaned directory is found (session no longer in DB).
"""

import asyncio
import base64
import hashlib
import io
import re
import shutil
from datetime import datetime, timezone
from pathlib import Path

import structlog
from PIL import Image

from navi.config import settings

log = structlog.get_logger()

# Extensions that must never be uploaded
_FORBIDDEN_EXTENSIONS = {
    ".exe", ".dll", ".so", ".dylib",
    ".sh", ".bash", ".zsh", ".fish",
    ".bat", ".cmd", ".ps1", ".vbs",
    ".com", ".msi", ".app", ".deb", ".rpm",
    ".bin", ".elf",
}


def session_dir(session_id: str) -> Path:
    return Path(settings.session_files_dir) / session_id


def ensure_session_dir(session_id: str) -> Path:
    d = session_dir(session_id)
    d.mkdir(parents=True, exist_ok=True)
    return d


def is_forbidden(filename: str) -> bool:
    return Path(filename).suffix.lower() in _FORBIDDEN_EXTENSIONS


def safe_filename(name: str) -> str:
    """Strip directory components and replace problematic chars."""
    name = Path(name).name  # strip any path components
    name = name.replace("/", "_").replace("\\", "_").replace("\x00", "_")
    return name or "upload"


# The formats `image_view` can open. A pasted image is written to disk for exactly
# that tool to reach, so this set is the one worth naming files after.
_IMAGE_EXTENSIONS = {
    "PNG": ".png",
    "JPEG": ".jpg",
    "WEBP": ".webp",
    "GIF": ".gif",
    "BMP": ".bmp",
}


def _decode_image(data: str) -> bytes:
    """Decode one image from the chat, tolerating a `data:` prefix."""
    raw = data.split(",", 1)[1] if data.startswith("data:") and "," in data else data
    return base64.b64decode(raw)


def _sniff_extension(raw: bytes) -> str | None:
    """The extension for what the bytes actually are, or None if they are not an image."""
    with Image.open(io.BytesIO(raw)) as image:
        return _IMAGE_EXTENSIONS.get((image.format or "").upper())


_STORED_NAME = re.compile(r"image_\d+")


def _stored_images(directory: Path) -> list[Path]:
    """The pasted images already in the directory, in the order they were numbered.

    Only names this module writes count: an upload called `image_notes.md` shares the
    prefix but not the shape, and must not steal a number from the sequence.
    """
    stored = [
        path
        for path in directory.iterdir()
        if path.is_file()
        and _STORED_NAME.fullmatch(path.stem)
        and path.suffix.lower() in _IMAGE_EXTENSIONS.values()
    ]
    return sorted(stored, key=lambda path: int(path.stem.split("_", 1)[1]))


def _store_image(directory: Path, raw: bytes, ext: str) -> str:
    """Write `raw` under a free `image_N<ext>` name and return that name.

    Numbering runs across every pasted image, not per format, so the directory reads as
    the order the user sent them in. Bytes already on disk are reused rather than written
    again, so a message that was sent twice does not litter the directory with copies.
    """
    digest = hashlib.sha256(raw).digest()
    stored = _stored_images(directory)
    for path in stored:
        if path.stat().st_size == len(raw) and hashlib.sha256(path.read_bytes()).digest() == digest:
            return path.name
    taken = {int(path.stem.split("_", 1)[1]) for path in stored}
    number = 1
    while number in taken:
        number += 1
    name = f"image_{number}{ext}"
    (directory / name).write_bytes(raw)
    return name


def save_images(session_id: str, images: list[str]) -> list[str]:
    """Write the images a user pasted into the chat into the session directory.

    Returns one name per input image, in order; an empty string marks one that could
    not be stored. The bytes are written exactly as they arrived — re-encoding to JPEG
    would drop the alpha channel of a pasted PNG, which is often the point of pasting it.

    Raising is left to the caller for the one failure it can act on: an `OSError` when
    the session directory itself cannot be created. A single unreadable image is logged
    and skipped so it cannot cost the rest of the message.
    """
    directory = ensure_session_dir(session_id)
    names: list[str] = []
    for index, data in enumerate(images, start=1):
        try:
            raw = _decode_image(data)
            ext = _sniff_extension(raw)
            if ext is None:
                raise ValueError("unrecognised image format")
            names.append(_store_image(directory, raw, ext))
        except (OSError, ValueError) as exc:
            log.warning(
                "session_files.image_not_stored",
                session_id=session_id,
                index=index,
                error=str(exc),
            )
            names.append("")
    return names


async def delete_session_dir(session_id: str) -> None:
    d = session_dir(session_id)
    if d.exists():
        await asyncio.to_thread(shutil.rmtree, d)
        log.info("session_files.deleted", session_id=session_id)


async def cleanup_orphans(store) -> None:
    """Remove session file dirs whose session no longer exists in the database."""
    root = Path(settings.session_files_dir)
    if not root.exists():
        return

    entries = await asyncio.to_thread(lambda: list(root.iterdir()))
    for d in entries:
        if not d.is_dir():
            continue
        session_id = d.name
        try:
            session = await store.get(session_id)
        except Exception:
            continue

        if session is None:
            await asyncio.to_thread(shutil.rmtree, d)
            log.info("session_files.orphan_deleted", session_id=session_id)


def list_session_files(session_id: str, max_depth: int = 10) -> list[dict]:
    """Recursively list files and directories in the session directory.

    Returns a flat list ordered by path, each item containing:
    { path, size, is_dir, modified_at }.
    Hidden files (starting with '.') are included.
    """
    base = session_dir(session_id)
    if not base.exists():
        return []

    out = []
    _scan_dir(base, base, out, current_depth=0, max_depth=max_depth)
    return out


def _scan_dir(root: Path, current: Path, out: list, current_depth: int, max_depth: int) -> None:
    if current_depth > max_depth:
        return
    try:
        entries = sorted(current.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower()))
    except (PermissionError, OSError):
        return
    for entry in entries:
        try:
            stat = entry.stat()
            rel = entry.relative_to(root).as_posix()
            out.append({
                "path": rel,
                "size": stat.st_size if entry.is_file() else 0,
                "is_dir": entry.is_dir(),
                "modified_at": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat(),
            })
            if entry.is_dir():
                _scan_dir(root, entry, out, current_depth + 1, max_depth)
        except (PermissionError, OSError):
            continue


async def cleanup_loop(store) -> None:
    """Background task: remove orphaned session file dirs every hour."""
    while True:
        try:
            await cleanup_orphans(store)
        except Exception:
            log.exception("session_files.cleanup_error")
        await asyncio.sleep(3600)
