"""What a file in a session directory actually is.

Five extension tables used to answer this question, and they disagreed: one in
``content_store``, one in ``content_publish``'s schema, and three in the
webclient. A ``.md`` report came back "unknown" and got a download link, while
``mimetypes`` — which reads the host's ``/etc/mime.types`` — served a
TypeScript ``.ts`` file as ``video/mp2t`` because the extension is shared with
MPEG transport streams.

Detection order:

1. A strong binary signature in the file's opening kilobytes. Text cannot
   start with the PNG or RIFF magic, so the signature wins over the extension:
   a file saved under the wrong name still reaches the right viewer.
2. The extension table, which answers everything without a telltale header.
3. For a file with no usable extension: does it look like text?
4. Otherwise unknown.

The values in ``CONTENT_TYPES`` are what ``content_publish`` accepts and what
the webclient branches on; adding one means adding a viewer branch there too.
"""

from __future__ import annotations

from pathlib import Path

CONTENT_TYPES = (
    "stl",
    "model",
    "html",
    "svg",
    "pdf",
    "image",
    "video",
    "audio",
    "text",
    "unknown",
)

# Enough for every signature below, including a Matroska track entry.
_SAMPLE_BYTES = 4096

# (content type, MIME). `stl` and `model` share one viewer: the file's own
# extension decides which three.js loader opens it.
_EXTENSION_TYPES: dict[str, tuple[str, str]] = {
    # 3D models
    ".stl": ("stl", "model/stl"),
    ".glb": ("model", "model/gltf-binary"),
    ".gltf": ("model", "model/gltf+json"),
    # Documents
    ".html": ("html", "text/html"),
    ".htm": ("html", "text/html"),
    ".svg": ("svg", "image/svg+xml"),
    ".pdf": ("pdf", "application/pdf"),
    # Images
    ".png": ("image", "image/png"),
    ".jpg": ("image", "image/jpeg"),
    ".jpeg": ("image", "image/jpeg"),
    ".gif": ("image", "image/gif"),
    ".webp": ("image", "image/webp"),
    ".bmp": ("image", "image/bmp"),
    ".avif": ("image", "image/avif"),
    ".ico": ("image", "image/x-icon"),
    # Video
    ".mp4": ("video", "video/mp4"),
    ".webm": ("video", "video/webm"),
    ".mov": ("video", "video/quicktime"),
    ".mkv": ("video", "video/x-matroska"),
    ".m4v": ("video", "video/x-m4v"),
    ".ogv": ("video", "video/ogg"),
    # Audio
    ".mp3": ("audio", "audio/mpeg"),
    ".wav": ("audio", "audio/wav"),
    ".ogg": ("audio", "audio/ogg"),
    ".oga": ("audio", "audio/ogg"),
    ".opus": ("audio", "audio/ogg"),
    ".m4a": ("audio", "audio/mp4"),
    ".flac": ("audio", "audio/flac"),
    ".aac": ("audio", "audio/aac"),
    ".weba": ("audio", "audio/webm"),
    # Text and source. `.ts` belongs here, not to video: without an MPEG
    # transport-stream signature a .ts file is TypeScript.
    ".txt": ("text", "text/plain"),
    ".text": ("text", "text/plain"),
    ".log": ("text", "text/plain"),
    ".md": ("text", "text/markdown"),
    ".markdown": ("text", "text/markdown"),
    ".csv": ("text", "text/csv"),
    ".tsv": ("text", "text/tab-separated-values"),
    ".json": ("text", "application/json"),
    ".jsonl": ("text", "application/json"),
    ".ndjson": ("text", "application/json"),
    ".yaml": ("text", "application/yaml"),
    ".yml": ("text", "application/yaml"),
    ".xml": ("text", "text/xml"),
    ".ini": ("text", "text/plain"),
    ".cfg": ("text", "text/plain"),
    ".conf": ("text", "text/plain"),
    ".toml": ("text", "text/plain"),
    ".py": ("text", "text/x-python"),
    ".js": ("text", "text/javascript"),
    ".mjs": ("text", "text/javascript"),
    ".cjs": ("text", "text/javascript"),
    ".ts": ("text", "text/plain"),
    ".tsx": ("text", "text/plain"),
    ".jsx": ("text", "text/javascript"),
    ".css": ("text", "text/css"),
    ".scss": ("text", "text/plain"),
    ".sass": ("text", "text/plain"),
    ".less": ("text", "text/plain"),
    ".sh": ("text", "text/x-sh"),
    ".bash": ("text", "text/x-sh"),
    ".zsh": ("text", "text/x-sh"),
    ".sql": ("text", "text/plain"),
    ".scad": ("text", "text/plain"),
    ".srt": ("text", "text/plain"),
    ".vtt": ("text", "text/vtt"),
    ".diff": ("text", "text/plain"),
    ".patch": ("text", "text/plain"),
    ".tex": ("text", "text/plain"),
    ".c": ("text", "text/x-csrc"),
    ".h": ("text", "text/x-chdr"),
    ".cpp": ("text", "text/x-c++src"),
    ".hpp": ("text", "text/x-c++hdr"),
    ".java": ("text", "text/x-java"),
    ".go": ("text", "text/plain"),
    ".rs": ("text", "text/plain"),
    ".rb": ("text", "text/plain"),
    ".php": ("text", "text/plain"),
    ".kt": ("text", "text/plain"),
    ".swift": ("text", "text/plain"),
    # Opaque containers: no viewer, but the MIME still matters for the download
    # and keeps the text heuristic from calling a small archive "text".
    ".zip": ("unknown", "application/zip"),
    ".tar": ("unknown", "application/x-tar"),
    ".gz": ("unknown", "application/gzip"),
    ".tgz": ("unknown", "application/gzip"),
    ".7z": ("unknown", "application/x-7z-compressed"),
    ".rar": ("unknown", "application/vnd.rar"),
    ".docx": ("unknown", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
    ".xlsx": ("unknown", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
    ".pptx": ("unknown", "application/vnd.openxmlformats-officedocument.presentationml.presentation"),
    ".odt": ("unknown", "application/vnd.oasis.opendocument.text"),
    ".ods": ("unknown", "application/vnd.oasis.opendocument.spreadsheet"),
    ".rtf": ("unknown", "application/rtf"),
    ".epub": ("unknown", "application/epub+zip"),
}

# MIME families a browser can render itself. `video/` and `audio/` used to be
# missing here, so every media file was sent as an attachment and the player
# got a download instead of a stream.
_INLINE_PREFIXES = ("image/", "audio/", "video/", "text/")
_INLINE_MIMES = frozenset({"application/pdf", "application/json"})

_VIDEO_CODECS = (b"V_VP8", b"V_VP9", b"V_AV1", b"V_MPEG4", b"V_MPEGH", b"V_THEORA")
_AUDIO_CODECS = (b"A_OPUS", b"A_VORBIS", b"A_AAC", b"A_FLAC", b"A_MPEG")


def _read_head(path: str | Path | None) -> bytes:
    if path is None:
        return b""
    try:
        with Path(path).open("rb") as handle:
            return handle.read(_SAMPLE_BYTES)
    except OSError:
        return b""


def _mpeg_audio_frame(head: bytes) -> bool:
    """An MP3 without an ID3 tag starts with a frame header, not with a magic.

    The check is deliberately narrow — reserved layer and invalid bitrate
    are rejected — so ordinary binary data does not read as audio.
    """
    if len(head) < 4 or head[0] != 0xFF or (head[1] & 0xE0) != 0xE0:
        return False
    layer = (head[1] >> 1) & 0x03
    bitrate = (head[2] >> 4) & 0x0F
    return layer != 0 and bitrate != 0x0F


def _mpeg_transport_stream(head: bytes) -> bool:
    """MPEG-TS: 188-byte packets, each starting with 0x47."""
    if len(head) < 377:
        return False
    return head[0] == 0x47 and head[188] == 0x47 and head[376] == 0x47


def _matroska(head: bytes) -> tuple[str, str] | None:
    """WebM/MKV, audio or video by the tracks it declares."""
    is_webm = b"webm" in head[:64]
    if any(codec in head for codec in _VIDEO_CODECS):
        return "video", "video/webm" if is_webm else "video/x-matroska"
    if any(codec in head for codec in _AUDIO_CODECS):
        return "audio", "audio/webm" if is_webm else "audio/x-matroska"
    return None


def _signature(head: bytes) -> tuple[str, str] | None:
    """A binary format the opening bytes give away, or None."""
    if not head:
        return None
    if head.startswith(b"%PDF-"):
        return "pdf", "application/pdf"
    if head.startswith(b"\x89PNG\r\n\x1a\n"):
        return "image", "image/png"
    if head.startswith(b"\xff\xd8\xff"):
        return "image", "image/jpeg"
    if head.startswith((b"GIF87a", b"GIF89a")):
        return "image", "image/gif"
    if head.startswith(b"RIFF") and len(head) >= 12:
        kind = head[8:12]
        if kind == b"WEBP":
            return "image", "image/webp"
        if kind == b"WAVE":
            return "audio", "audio/wav"
        if kind == b"AVI ":
            return "video", "video/x-msvideo"
    if head.startswith(b"OggS"):
        return ("video", "video/ogg") if b"theora" in head[:1024] else ("audio", "audio/ogg")
    if head.startswith(b"fLaC"):
        return "audio", "audio/flac"
    if head.startswith(b"ID3"):
        return "audio", "audio/mpeg"
    if _mpeg_audio_frame(head):
        return "audio", "audio/mpeg"
    if head.startswith(b"\x1a\x45\xdf\xa3"):
        return _matroska(head)
    if len(head) >= 12 and head[4:8] == b"ftyp":
        brand = head[8:12]
        if brand in {b"M4A ", b"M4B ", b"M4P ", b"mp4a"}:
            return "audio", "audio/mp4"
        if brand in {b"avif", b"avis"}:
            return "image", "image/avif"
        return "video", "video/mp4"
    if head.startswith(b"glTF"):
        return "model", "model/gltf-binary"
    if _mpeg_transport_stream(head):
        return "video", "video/mp2t"
    return None


def _looks_like_text(head: bytes) -> bool:
    """True when the sample decodes as text rather than as arbitrary bytes."""
    if not head or b"\x00" in head:
        return False
    try:
        text = head.decode("utf-8")
    except UnicodeDecodeError:
        # A UTF-8 sequence cut at the sample boundary, or a legacy encoding;
        # latin-1 always decodes, and the printable ratio still separates
        # prose from compressed data.
        text = head.decode("latin-1")
    if not text:
        return False
    printable = sum(1 for ch in text if ch.isprintable() or ch in "\n\r\t\f\v")
    return printable / len(text) >= 0.9


def detect(filename: str, path: str | Path | None = None) -> tuple[str, str]:
    """Return ``(content_type, mime)`` for a file.

    ``path`` is read for its signature; without it only the name is used.
    """
    suffix = Path(filename).suffix.lower()
    head = _read_head(path)

    found = _signature(head)
    if found is not None:
        return found

    known = _EXTENSION_TYPES.get(suffix)
    if known is not None:
        return known

    if _looks_like_text(head):
        return "text", "text/plain"
    return "unknown", "application/octet-stream"


def content_type_of(filename: str, path: str | Path | None = None) -> str:
    return detect(filename, path)[0]


def mime_of(filename: str, path: str | Path | None = None) -> str:
    return detect(filename, path)[1]


def normalize_content_type(value: str | None) -> str | None:
    """An explicitly requested type, or None when the caller should detect.

    A model naming a type we do not have a viewer for is not an error; it is a
    missing hint, so detection answers instead of the value being stored as is.
    """
    if not value:
        return None
    value = value.strip().lower()
    if value in CONTENT_TYPES and value != "unknown":
        return value
    return None


def is_inline_mime(mime: str) -> bool:
    """Whether a browser can show this response in place instead of saving it."""
    return mime.startswith(_INLINE_PREFIXES) or mime in _INLINE_MIMES
