"""FC-3e: cursor-paginated read service for the Posts stream.

Uses the shared `pagination` cursor (base64 of "<iso8601_sort_key>|<id>") so
every feed paginates identically. Sort key here is COALESCE(Post.post_date,
Post.downloaded_at) so posts without a publish date sort by when we captured them.

Pure read-surface; no writes. The service composes the post dict
(thumbnails from every image linked to the post — its own primary images
plus cross-posted duplicates via image_provenance — and non-media
attachments from PostAttachment) so the API layer can jsonify directly.
"""
from __future__ import annotations

from html import unescape

from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession

from ..models import (
    Artist,
    ExternalLink,
    ImageProvenance,
    ImageRecord,
    Post,
    PostAttachment,
    Source,
    attachment_download_url,
)
from ..utils.html_sanitize import (
    extract_img_srcs,
    rewrite_img_srcs,
    sanitize_post_html,
)
from ..utils.paths import filehash_from_url
from ..utils.text import html_to_plain, truncate_at_word
from .gallery_service import image_url, thumbnail_url
from .pagination import decode_cursor, encode_cursor

DESCRIPTION_LIMIT = 280
THUMBNAIL_LIMIT = 6


def _sort_key():
    """Postgres COALESCE expression used in ORDER BY and WHERE clauses."""
    return func.coalesce(Post.post_date, Post.downloaded_at)


class PostFeedService:
    def __init__(self, session: AsyncSession):
        self.session = session

    async def scroll(
        self,
        *,
        cursor: str | None = None,
        artist_id: int | None = None,
        platform: str | None = None,
        q: str | None = None,
        limit: int = 24,
        direction: str = "older",
    ) -> dict:
        """Paginate the feed from `cursor`. direction='older' walks back in
        time (default, infinite-scroll down); direction='newer' walks forward
        (scroll up in an anchored view). Items are always returned in feed
        (descending) order; `next_cursor` points to the far edge in the
        requested direction (null when exhausted).

        `q` is a free-text filter (ILIKE substring over post_title OR
        description) applied INSIDE the artist/platform scope, so a search
        from the Browse bar stays within whatever artist is filtered in
        view (operator-asked 2026-06-11)."""
        if limit < 1 or limit > 100:
            raise ValueError("limit must be between 1 and 100")
        if direction not in ("older", "newer"):
            raise ValueError("direction must be 'older' or 'newer'")

        sort_key = _sort_key()
        # Artist via the denormalized Post.artist_id (alembic 0030);
        # Source via LEFT JOIN since post.source_id can now be NULL for
        # filesystem-imported posts with no live subscription. A
        # platform= filter implicitly excludes NULL-source posts (they
        # have no platform); an artist_id= filter still surfaces them
        # because Post.artist_id is always set.
        stmt = (
            select(Post, Artist, Source)
            .join(Artist, Post.artist_id == Artist.id)
            .outerjoin(Source, Post.source_id == Source.id)
        )
        if artist_id is not None:
            stmt = stmt.where(Post.artist_id == artist_id)
        if platform is not None:
            stmt = stmt.where(Source.platform == platform)
        if q:
            like = f"%{q}%"
            stmt = stmt.where(or_(
                Post.post_title.ilike(like),
                Post.description.ilike(like),
            ))
        if cursor:
            cur_ts, cur_id = decode_cursor(cursor)
            if direction == "older":
                stmt = stmt.where(or_(
                    sort_key < cur_ts,
                    and_(sort_key == cur_ts, Post.id < cur_id),
                ))
            else:
                stmt = stmt.where(or_(
                    sort_key > cur_ts,
                    and_(sort_key == cur_ts, Post.id > cur_id),
                ))

        if direction == "older":
            stmt = stmt.order_by(sort_key.desc(), Post.id.desc())
        else:
            stmt = stmt.order_by(sort_key.asc(), Post.id.asc())
        stmt = stmt.limit(limit + 1)
        rows = (await self.session.execute(stmt)).all()

        has_more = len(rows) > limit
        rows = rows[:limit]
        if direction == "newer":
            # Fetched ascending (closest-newer first); flip to feed order.
            rows = list(reversed(rows))

        next_cursor: str | None = None
        if has_more and rows:
            # Far edge in the travel direction: oldest row going older,
            # newest row going newer (rows is descending for display).
            edge_post = rows[-1][0] if direction == "older" else rows[0][0]
            edge_key = edge_post.post_date or edge_post.downloaded_at
            next_cursor = encode_cursor(edge_key, edge_post.id)

        post_ids = [p.id for p, _, _ in rows]
        thumbs_map = await self._thumbnails_for(post_ids)
        atts_map = await self._attachments_for(post_ids)

        items = [
            self._to_dict(post, artist, source, thumbs_map, atts_map)
            for post, artist, source in rows
        ]
        return {"items": items, "next_cursor": next_cursor}

    async def around(
        self,
        *,
        post_id: int,
        artist_id: int | None = None,
        platform: str | None = None,
        q: str | None = None,
        limit: int = 12,
    ) -> dict | None:
        """A window centered on `post_id`: up to `limit` newer posts + the
        post + up to `limit` older posts, in feed (descending) order, with a
        cursor for each end. Returns None if the post doesn't exist."""
        anchor = (await self.session.execute(
            select(Post, Artist, Source)
            .join(Artist, Post.artist_id == Artist.id)
            .outerjoin(Source, Post.source_id == Source.id)
            .where(Post.id == post_id)
        )).one_or_none()
        if anchor is None:
            return None
        anchor_post, anchor_artist, anchor_source = anchor
        anchor_key = anchor_post.post_date or anchor_post.downloaded_at
        anchor_cursor = encode_cursor(anchor_key, anchor_post.id)

        older = await self.scroll(
            cursor=anchor_cursor, artist_id=artist_id, platform=platform,
            q=q, limit=limit, direction="older",
        )
        newer = await self.scroll(
            cursor=anchor_cursor, artist_id=artist_id, platform=platform,
            q=q, limit=limit, direction="newer",
        )
        thumbs_map = await self._thumbnails_for([anchor_post.id])
        atts_map = await self._attachments_for([anchor_post.id])
        anchor_item = self._to_dict(
            anchor_post, anchor_artist, anchor_source, thumbs_map, atts_map,
        )
        return {
            "items": newer["items"] + [anchor_item] + older["items"],
            "cursor_older": older["next_cursor"],
            "cursor_newer": newer["next_cursor"],
            "anchor_id": anchor_post.id,
        }

    async def get_post(self, post_id: int) -> dict | None:
        row = (await self.session.execute(
            select(Post, Artist, Source)
            .join(Artist, Post.artist_id == Artist.id)
            .outerjoin(Source, Post.source_id == Source.id)
            .where(Post.id == post_id)
        )).one_or_none()
        if row is None:
            return None
        post, artist, source = row
        # Detail endpoint returns the FULL image list for PostModal's
        # masonry grid — feed query still caps at THUMBNAIL_LIMIT via
        # the default arg.
        thumbs_map = await self._thumbnails_for([post.id], limit=None)
        atts_map = await self._attachments_for([post.id])
        item = self._to_dict(post, artist, source, thumbs_map, atts_map)
        item["description_full"] = html_to_plain(post.description)
        # Full (uncapped) translated description for the detail view (#143).
        item["description_translated_full"] = post.description_translated
        # Sanitized HTML body for faithful (semantic) rendering in the post view;
        # detail-only (the feed list stays lightweight plain text). None when the
        # post has no body. Inline `<img>` sources are remapped to locally-served
        # copies (#830 Phase 2) so the body never hotlinks the public CDN.
        item["description_html"] = await self._localize_inline_images(
            sanitize_post_html(post.description), post.artist_id,
        )
        item["external_links"] = await self._external_links_for(post.id)
        return item

    async def _localize_inline_images(
        self, html: str | None, artist_id: int | None,
    ) -> str | None:
        """Rewrite a post body's inline `<img src=CDN>` to locally-served copies.

        The join key is the CDN filehash the downloader persisted on each
        ImageRecord (source_filehash): for every body image whose filehash maps
        to a stored image of THIS artist, swap the src to /images/<path>. Images
        we never captured (or pre-Phase-2 rows with no filehash) are left as-is —
        they keep hotlinking, which is the prior behavior. Scoped to the post's
        artist so one creator's body never resolves to another's file."""
        if not html or artist_id is None:
            return html
        srcs = extract_img_srcs(html)
        if not srcs:
            return html
        # filehash -> the raw (as-in-HTML) src strings carrying it. A body can
        # repeat the same image; keep every raw form so each is substituted.
        by_hash: dict[str, list[str]] = {}
        for raw in srcs:
            fh = filehash_from_url(unescape(raw))
            if fh:
                by_hash.setdefault(fh, []).append(raw)
        if not by_hash:
            return html
        rows = (await self.session.execute(
            select(ImageRecord.source_filehash, ImageRecord.path)
            .where(
                ImageRecord.artist_id == artist_id,
                ImageRecord.source_filehash.in_(list(by_hash)),
            )
        )).all()
        replace: dict[str, str] = {}
        for fh, path in rows:
            for raw in by_hash.get(fh, ()):
                replace[raw] = image_url(path)
        return rewrite_img_srcs(html, replace)

    async def _external_links_for(self, post_id: int) -> list[dict]:
        """Off-platform file-host links recorded for a post (detail-only). Each
        carries its host, full url, label, and download status so the post view
        can surface them (and, later, a retry/download affordance)."""
        rows = (await self.session.execute(
            select(ExternalLink)
            .where(ExternalLink.post_id == post_id)
            .order_by(ExternalLink.id.asc())
        )).scalars().all()
        return [
            {
                "id": e.id, "host": e.host, "url": e.url,
                "label": e.label, "status": e.status,
            }
            for e in rows
        ]

    # --- composition helpers ---------------------------------------------

    async def _thumbnails_for(
        self, post_ids: list[int], *, limit: int | None = THUMBNAIL_LIMIT,
    ) -> dict[int, dict]:
        """post_id -> {"thumbs": [...up to limit], "more": int}.

        Selects up to `limit` images per post via window function so we
        can detect overflow in a single query. Pass `limit=None` to
        return ALL thumbnails per post (used by `get_post` for PostModal's
        masonry grid; the feed pass keeps the default cap so payloads
        stay small).
        """
        if not post_ids:
            return {}
        # A post shows EVERY image linked to it — both its own primary images and
        # cross-posted duplicates linked via image_provenance (a near-dup of an
        # existing image gets a provenance row for the new post, not dropped; see
        # image_provenance docstring + the importer enrich-on-duplicate path). The
        # UNION dedups (image, post) pairs and also keeps any legacy image that has
        # a primary_post_id but no provenance row. Partition the window on the
        # link's post_id, not ImageRecord.primary_post_id, so a duplicate counts
        # under each post it belongs to.
        links = (
            select(
                ImageProvenance.image_record_id.label("image_id"),
                ImageProvenance.post_id.label("post_id"),
            )
            .where(ImageProvenance.post_id.in_(post_ids))
            .union(
                select(
                    ImageRecord.id.label("image_id"),
                    ImageRecord.primary_post_id.label("post_id"),
                ).where(ImageRecord.primary_post_id.in_(post_ids))
            )
            .subquery()
        )
        # Rank images within each post; cap at `limit` rows per post when
        # limit is set, return all when limit is None.
        ranked = (
            select(
                ImageRecord.id,
                links.c.post_id,
                ImageRecord.sha256,
                ImageRecord.mime,
                ImageRecord.thumbnail_path,
                func.row_number().over(
                    partition_by=links.c.post_id,
                    order_by=ImageRecord.id.asc(),
                ).label("rn"),
                func.count(ImageRecord.id).over(
                    partition_by=links.c.post_id,
                ).label("total"),
            )
            .join(ImageRecord, ImageRecord.id == links.c.image_id)
            .subquery()
        )
        stmt = select(
            ranked.c.id, ranked.c.post_id,
            ranked.c.sha256, ranked.c.mime, ranked.c.thumbnail_path, ranked.c.total,
        )
        if limit is not None:
            stmt = stmt.where(ranked.c.rn <= limit)
        rows = (await self.session.execute(stmt)).all()

        out: dict[int, dict] = {pid: {"thumbs": [], "more": 0} for pid in post_ids}
        for img_id, pid, sha, mime, tp, total in rows:
            entry = out.setdefault(pid, {"thumbs": [], "more": 0})
            entry["thumbs"].append({
                "image_id": img_id,
                "thumbnail_url": thumbnail_url(tp, sha, mime),
                "mime": mime,
            })
            # `total` is constant per partition; overflow = total - THUMBNAIL_LIMIT.
            entry["more"] = max(0, total - THUMBNAIL_LIMIT)
        return out

    async def _attachments_for(self, post_ids: list[int]) -> dict[int, list[dict]]:
        if not post_ids:
            return {}
        rows = (await self.session.execute(
            select(PostAttachment)
            .where(PostAttachment.post_id.in_(post_ids))
            .order_by(PostAttachment.id.asc())
        )).scalars().all()
        out: dict[int, list[dict]] = {pid: [] for pid in post_ids}
        for att in rows:
            out.setdefault(att.post_id, []).append({
                "id": att.id,
                "original_filename": att.original_filename,
                "ext": att.ext,
                "mime": att.mime,
                "size_bytes": att.size_bytes,
                "download_url": attachment_download_url(att.id),
            })
        return out

    def _to_dict(
        self, post: Post, artist: Artist, source: Source | None,
        thumbs_map: dict, atts_map: dict,
    ) -> dict:
        plain_full = html_to_plain(post.description) if post.description else None
        if plain_full is None:
            description_plain, truncated = None, False
        else:
            description_plain, truncated = truncate_at_word(plain_full, DESCRIPTION_LIMIT)
        # Translation (#143): the stored translated description is already plain
        # text; truncate it the same way for the card.
        desc_trans = post.description_translated
        desc_trans_short = (
            truncate_at_word(desc_trans, DESCRIPTION_LIMIT)[0] if desc_trans else None
        )
        thumbs_entry = thumbs_map.get(post.id, {"thumbs": [], "more": 0})
        # `source` is null for filesystem-imported posts with no live
        # subscription (alembic 0030). Frontend renders that as a
        # "filesystem import" affordance instead of a platform chip.
        return {
            "id": post.id,
            "external_post_id": post.external_post_id,
            "post_url": post.post_url,
            "post_title": post.post_title,
            "post_date": post.post_date.isoformat() if post.post_date else None,
            "downloaded_at": post.downloaded_at.isoformat(),
            "description_plain": description_plain,
            "description_truncated": truncated,
            # Translation-forward fields (#143): shown by default when present;
            # UI toggles back to the originals above. Source lang labels the toggle.
            "post_title_translated": post.post_title_translated,
            "description_translated": desc_trans_short,
            "translated_source_lang": post.translated_source_lang,
            # Sticky per-post translation choice (auto/force/original, #155).
            "translation_override": post.translation_override,
            "artist": {"id": artist.id, "name": artist.name, "slug": artist.slug},
            "source": (
                {"id": source.id, "platform": source.platform}
                if source is not None else None
            ),
            "thumbnails": thumbs_entry["thumbs"],
            "thumbnails_more": thumbs_entry["more"],
            "attachments": atts_map.get(post.id, []),
        }
