"""Note-media file handling — uploads, safe path resolution, deletion.

Port of v1 main.py:8106-8181 (`_ext_for`, `_save_upload`, `_delete_note_file`,
`_safe_note_path` + the ALLOWED_*_EXT / MAX_*_BYTES limits), unchanged in
behavior:

- Files live under ``<DATA_DIR>/notes/YYYY-MM/<uuid><ext>`` and are referenced
  everywhere by that relative path (``YYYY-MM/<uuid><ext>``) — the exact
  strings carried in ``field_notes.audio_path`` / ``photo_paths`` (deviation
  D3), so v1-migrated paths keep resolving verbatim.
- Extension is taken from the filename when allowed, else guessed from the
  content type, else the per-kind default. Never from user input directly.
- Size caps enforced while streaming; an oversized upload is removed and
  rejected (v1: HTTP 413).
- ``safe_note_path`` is the single traversal gate: rejects absolute paths and
  any ``..`` segment, then re-checks containment after normalization, then
  requires an existing regular file.
"""
from __future__ import annotations

import mimetypes
import os
import uuid
from datetime import datetime, timezone

from fastapi import UploadFile

from ..config import get_settings
from ..errors import AppError

# --- error codes (media domain) ---
MEDIA_TOO_LARGE = "media_too_large"
MEDIA_NOT_FOUND = "media_not_found"

# v1 limits, verbatim (main.py:8108-8111).
ALLOWED_AUDIO_EXT = {".webm", ".ogg", ".oga", ".m4a", ".mp4", ".mp3", ".wav"}
ALLOWED_IMAGE_EXT = {".jpg", ".jpeg", ".png", ".heic", ".webp", ".gif"}
MAX_AUDIO_BYTES = 50 * 1024 * 1024   # 50 MB
MAX_IMAGE_BYTES = 25 * 1024 * 1024   # 25 MB per image


def notes_dir() -> str:
    """Root of all note media, under the configured data dir."""
    return os.path.join(get_settings().data_dir, "notes")


def _ext_for(filename: str, content_type: str | None, allowed: set, default: str) -> str:
    base = (filename or "").lower()
    for ext in allowed:
        if base.endswith(ext):
            return ext
    if content_type:
        guessed = mimetypes.guess_extension(content_type.split(";")[0].strip()) or ""
        if guessed.lower() in allowed:
            return guessed.lower()
    return default


def save_upload(upload: UploadFile, kind: str) -> str:
    """Save an upload under notes/YYYY-MM/<uuid><ext>; return the relative path.

    ``kind`` is 'audio' or 'photo' (anything non-audio uses the image rules,
    as in v1). Raises AppError(MEDIA_TOO_LARGE, 413) past the size cap; the
    partial file is removed first.
    """
    if kind == "audio":
        allowed, default, max_bytes = ALLOWED_AUDIO_EXT, ".webm", MAX_AUDIO_BYTES
    else:
        allowed, default, max_bytes = ALLOWED_IMAGE_EXT, ".jpg", MAX_IMAGE_BYTES
    ext = _ext_for(upload.filename or "", upload.content_type, allowed, default)
    month = datetime.now(timezone.utc).strftime("%Y-%m")
    abs_dir = os.path.join(notes_dir(), month)
    os.makedirs(abs_dir, exist_ok=True)
    name = f"{uuid.uuid4().hex}{ext}"
    rel_path = f"{month}/{name}"
    abs_path = os.path.join(notes_dir(), rel_path)
    size = 0
    with open(abs_path, "wb") as f:
        while True:
            chunk = upload.file.read(1024 * 1024)
            if not chunk:
                break
            size += len(chunk)
            if size > max_bytes:
                f.close()
                os.remove(abs_path)
                raise AppError(
                    MEDIA_TOO_LARGE,
                    f"{kind} upload exceeds {max_bytes // (1024 * 1024)} MB",
                    413,
                )
            f.write(chunk)
    return rel_path


def safe_note_path(rel_path: str) -> str | None:
    """Resolve a relative note path to an absolute path under notes_dir().

    Returns None for anything unsafe (absolute path, ``..`` traversal,
    escape after normalization) or that isn't an existing regular file.
    """
    if not rel_path or rel_path.startswith("/") or ".." in rel_path.split("/"):
        return None
    root = os.path.normpath(notes_dir())
    abs_path = os.path.normpath(os.path.join(root, rel_path))
    if not abs_path.startswith(root + os.sep) and abs_path != root:
        return None
    if not os.path.isfile(abs_path):
        return None
    return abs_path


def delete_note_file(rel_path: str) -> None:
    """Best-effort delete of a file under notes_dir(). Silent on failure."""
    if not rel_path:
        return
    abs_path = safe_note_path(rel_path)
    if abs_path:
        try:
            os.remove(abs_path)
        except OSError:
            pass
