"""Minimal gallery-dl sidecar parsing (one-time filesystem-import aid).

Per-platform quirks (post_url synthesis, key-chain overrides) live in
the platforms registry — `backend/app/services/platforms/`. This module
is platform-agnostic: it looks up `category` in the sidecar and asks
the registry for the right behavior.
"""

import re
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path

from ..services.platforms import (
    PLATFORMS,
    description_keys_for,
    external_post_id_keys_for,
)


@dataclass(frozen=True)
class SidecarData:
    platform: str | None
    external_post_id: str | None
    post_url: str | None
    post_title: str | None
    description: str | None
    attachment_count: int | None
    post_date: datetime | None
    # Per-FILE CDN/origin URL the media was downloaded from (#830 Phase 2). Only
    # the per-media sidecar carries it; post-only sidecars leave it None. The
    # importer persists it on the ImageRecord so the body's inline `<img src>`
    # can be matched (by filehash) to the local copy at render time.
    source_url: str | None
    raw: dict


# gallery-dl prefixes media filenames with `NN_` for in-post ordering
# (`01_HOLLOW-ICHIGO.png`, `02_HOLOW ICHIGO.zip`) but writes the sidecar
# under the attachment's stem WITHOUT that ordering prefix
# (`HOLLOW-ICHIGO.json`). Strip the prefix when looking for sidecars.
# Confirmed against real Patreon downloads 2026-05-26 — without this
# strip, every gallery-dl post-level sidecar was invisible to FC since
# FC-3 shipped (24 deep-refresh calls produced 0 Posts in operator's DB).
_NUMBERING_PREFIX = re.compile(r"^\d+_(.+)$")


def find_sidecar(media: Path) -> Path | None:
    # Attachment-level sidecars (image.jpg.json, image.json).
    for cand in (media.with_suffix(".json"), Path(str(media) + ".json")):
        if cand.is_file():
            return cand
    # gallery-dl post-numbered convention: strip the `NN_` prefix from
    # the stem and look for that.json in the same directory.
    m = _NUMBERING_PREFIX.match(media.stem)
    if m:
        cand = media.parent / f"{m.group(1)}.json"
        if cand.is_file():
            return cand
    return None


def _first_str(data: dict, keys: tuple[str, ...]) -> str | None:
    for k in keys:
        v = data.get(k)
        if isinstance(v, str) and v.strip():
            return v.strip()
    return None


def _first_id(data: dict, keys: tuple[str, ...]) -> str | None:
    """Like `_first_str` but accepts ints and rejects bool (Python's
    bool subclasses int, so a literal `"id": true` would otherwise
    yield external_post_id="True")."""
    for k in keys:
        v = data.get(k)
        if isinstance(v, bool):
            continue
        if isinstance(v, (str, int)) and str(v).strip():
            return str(v).strip()
    return None


# Strip HTML tags + collapse whitespace + take the first non-empty line.
# Used to derive a display title from a body when the platform doesn't
# expose a separate title field (subscribestar posts always write
# `title: ""` and put the leading sentence inside `content` as HTML).
# Truncated to 120 chars with an ellipsis if longer — long enough to be
# meaningful in a feed, short enough to fit a row.
_TAG_RE = re.compile(r"<[^>]+>")
_WS_RE = re.compile(r"\s+")


def _first_line_text(body: str, limit: int = 120) -> str | None:
    if not body:
        return None
    text = _TAG_RE.sub(" ", body)
    text = text.replace("\xa0", " ")
    # Split on hard line breaks first; the body-stripped HTML often
    # collapses to one logical line, in which case the first sentence
    # split is the next-best heuristic.
    for line in text.splitlines():
        line = _WS_RE.sub(" ", line).strip()
        if line:
            if len(line) > limit:
                return line[: limit - 1].rstrip() + "…"
            return line
    return None


def _parse_date(v) -> datetime | None:
    if isinstance(v, bool):
        return None
    if isinstance(v, (int, float)):
        try:
            return datetime.fromtimestamp(v, tz=UTC)
        except (OverflowError, OSError, ValueError):
            return None
    if isinstance(v, str):
        s = v.strip()
        if not s:
            return None
        if s.endswith("Z"):
            s = s[:-1] + "+00:00"
        try:
            dt = datetime.fromisoformat(s)
        except ValueError:
            return None
        return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
    return None


def parse_sidecar(data: dict) -> SidecarData:
    if not isinstance(data, dict):
        data = {}

    cat = data.get("category")
    platform = cat if isinstance(cat, str) and cat.strip() else None

    external_post_id = _first_id(data, external_post_id_keys_for(platform))

    pc = data.get("page_count")
    if isinstance(pc, bool):
        attachment_count = None
    elif isinstance(pc, int) and pc > 0:
        attachment_count = pc
    elif isinstance(data.get("images"), list):
        attachment_count = len(data["images"])
    else:
        attachment_count = None

    post_date = None
    for k in ("published_at", "created_at", "date", "create_date",
              "timestamp"):
        if k in data:
            post_date = _parse_date(data[k])
            if post_date is not None:
                break

    description = _first_str(data, description_keys_for(platform))

    # When `title` is empty (subscribestar always; sometimes elsewhere),
    # synthesize from the description body's first non-empty text line.
    # Patreon's explicit titles short-circuit the fallback.
    post_title = _first_str(data, ("title",))
    if post_title is None and description:
        post_title = _first_line_text(description)

    # post_url: ask the platform module to synthesize a permalink.
    # When the platform registers a `derive_post_url`, it owns the
    # field (the bare `url`/`post_url` value is a file CDN URL and
    # must NEVER be persisted). When it doesn't register one, trust
    # the sidecar's `url` (Patreon's case — real permalink).
    info = PLATFORMS.get(platform) if platform else None
    if info is not None and info.derive_post_url is not None:
        post_url = info.derive_post_url(data)
    else:
        post_url = _first_str(data, ("url", "post_url"))

    # Per-file source URL (#830 Phase 2): the native downloader writes it into
    # each media sidecar. `url` is the post permalink for Patreon (handled
    # above), so source_url has its own dedicated key and never collides.
    source_url = _first_str(data, ("source_url",))

    return SidecarData(
        platform=platform,
        external_post_id=external_post_id,
        post_url=post_url,
        post_title=post_title,
        description=description,
        attachment_count=attachment_count,
        post_date=post_date,
        source_url=source_url,
        raw=data,
    )
