"""Gallery API: cursor scroll, timeline, jump, image detail, facets."""

from datetime import UTC, datetime, timedelta

from quart import Blueprint, jsonify, request
from sqlalchemy import delete, select, update
from sqlalchemy.orm import aliased

from ..extensions import get_session
from ..models import (
    ImageRecord,
    PresentationReview,
    Tag,
    TagSuggestionRejection,
)
from ..models.tag import image_tag
from ..services.gallery_service import GalleryService, image_url, thumbnail_url

gallery_bp = Blueprint("gallery", __name__, url_prefix="/api/gallery")


def _image_json(i):
    """Serialize a GalleryImage for the scroll/similar list responses."""
    return {
        "id": i.id,
        "sha256": i.sha256,
        "mime": i.mime,
        "width": i.width,
        "height": i.height,
        "created_at": i.created_at.isoformat(),
        "posted_at": i.posted_at.isoformat() if i.posted_at else None,
        "thumbnail_url": i.thumbnail_url,
        "artist": i.artist,
    }


def _parse_date(raw):
    """Parse a YYYY-MM-DD query value to a UTC midnight datetime, or None.
    Raises ValueError (→ 400) on a malformed value."""
    if not raw:
        return None
    return datetime.strptime(raw, "%Y-%m-%d").replace(tzinfo=UTC)


def _parse_filters():
    """Parse the composable gallery filters from query args, returning
    ``(filters_dict, sort)``. Raises ValueError (→ 400) on malformed ids/dates.

    The structured tag filter (#6) is AND-of-OR plus exclusions:
    - `tag_id` accepts a single id or a comma-separated list — all ANDed
      (the include common case; back-compat).
    - `tag_or` is REPEATABLE; each instance is a comma-separated OR-group, and
      the image must match at least one tag from EACH group (groups ANDed).
    - `tag_not` is a comma-separated exclude list (image must carry none).

    `media` is image|video; `sort` is newest|oldest|posted_new|posted_old
    (default posted_new); `platform` selects one
    platform (or the UNSOURCED_PLATFORM sentinel); `untagged`/`no_artist` are
    boolean flags; `date_from`/`date_to` are inclusive calendar-day bounds
    (date_to is widened by a day so the whole day is covered by the service's
    half-open `< date_to`)."""
    tag_raw = request.args.get("tag_id")
    tag_ids = (
        [int(x) for x in tag_raw.split(",") if x.strip()] if tag_raw else None
    ) or None
    tag_or_groups = [
        grp for raw in request.args.getlist("tag_or")
        if (grp := [int(x) for x in raw.split(",") if x.strip()])
    ] or None
    not_raw = request.args.get("tag_not")
    tag_exclude = (
        [int(x) for x in not_raw.split(",") if x.strip()] if not_raw else None
    ) or None
    post_id_raw = request.args.get("post_id")
    post_id = int(post_id_raw) if post_id_raw else None
    artist_id_raw = request.args.get("artist_id")
    artist_id = int(artist_id_raw) if artist_id_raw else None
    media = request.args.get("media")
    media_type = media if media in ("image", "video") else None
    # newest/oldest key off effective_date (primary post / download); posted_new/
    # posted_old off earliest_post_date (original publish across all posts). The
    # default is posted_new so the grid leads with original publish date, not the
    # download/repost the primary post points at (operator-flagged 2026-07-01).
    sort = request.args.get("sort")
    sort = sort if sort in ("newest", "oldest", "posted_new", "posted_old") else "posted_new"
    platform = request.args.get("platform") or None
    untagged = request.args.get("untagged") in ("1", "true", "yes")
    no_artist = request.args.get("no_artist") in ("1", "true", "yes")
    # Show the presentation chrome (banner / editor screenshot) that the default
    # gallery hides — the Hidden view sets this (milestone 141).
    include_hidden = request.args.get("include_hidden") in ("1", "true", "yes")
    date_from = _parse_date(request.args.get("date_from"))
    date_to = _parse_date(request.args.get("date_to"))
    if date_to is not None:
        date_to += timedelta(days=1)  # inclusive of the date_to calendar day
    filters = {
        "tag_ids": tag_ids, "post_id": post_id, "artist_id": artist_id,
        "media_type": media_type,
        "tag_or_groups": tag_or_groups, "tag_exclude": tag_exclude,
        "platform": platform,
        "untagged": untagged, "no_artist": no_artist,
        "date_from": date_from, "date_to": date_to,
        "include_hidden": include_hidden,
    }
    return filters, sort


@gallery_bp.route("/scroll", methods=["GET"])
async def scroll():
    cursor = request.args.get("cursor") or None
    try:
        limit = int(request.args.get("limit", "50"))
        filters, sort = _parse_filters()
    except ValueError:
        return jsonify({"error": "invalid filter or limit parameter"}), 400

    async with get_session() as session:
        svc = GalleryService(session)
        try:
            page = await svc.scroll(
                cursor=cursor, limit=limit, sort=sort, **filters,
            )
        except ValueError as exc:
            return jsonify({"error": str(exc)}), 400

    return jsonify(
        {
            "images": [_image_json(i) for i in page.images],
            "next_cursor": page.next_cursor,
            "date_groups": [
                {"year": y, "month": m, "image_ids": ids} for y, m, ids in page.date_groups
            ],
        }
    )


@gallery_bp.route("/similar", methods=["GET"])
async def similar():
    """Visual "more like this": images ranked by cosine distance to the
    `similar_to` image's embedding. Composes with the scope filters (AND) but
    ignores post_id and sort. Bounded top-N, no cursor."""
    try:
        similar_to = int(request.args["similar_to"])
        limit = int(request.args.get("limit", "100"))
        filters, _sort = _parse_filters()
    except (KeyError, ValueError):
        return jsonify({"error": "similar_to query param required"}), 400
    # Explore passes exclude_wip=1 to also drop work-in-progress from the
    # rabbit-hole; the gallery's own "similar" button omits it (keeps wip, #1274).
    exclude_wip = request.args.get("exclude_wip") in ("1", "true", "True")
    # Explore reach (#1476): 0 = nearest (gallery default), →1 reaches into farther
    # distance bands so the walk can escape a dense cluster. exclude_ids = the
    # breadcrumb, so already-walked images aren't re-served as neighbours.
    try:
        reach = max(0.0, min(1.0, float(request.args.get("reach", "0"))))
    except ValueError:
        reach = 0.0
    exclude_ids = [
        int(x) for x in request.args.get("exclude_ids", "").split(",")
        if x.strip().isdigit()
    ] or None
    # post_id is the exclusive post-detail view — not a similarity scope.
    # include_hidden is a gallery-browse flag; similar() has its OWN presentation
    # exclusion (a similarity-quality concern, #1274), so drop it here (#141).
    scope = {
        k: v for k, v in filters.items() if k not in ("post_id", "include_hidden")
    }
    async with get_session() as session:
        svc = GalleryService(session)
        try:
            images = await svc.similar(
                image_id=similar_to, limit=limit, exclude_wip=exclude_wip,
                reach=reach, exclude_ids=exclude_ids, **scope)
        except ValueError as exc:
            return jsonify({"error": str(exc)}), 400
    if images is None:
        return jsonify({"error": "not found"}), 404
    return jsonify(
        {
            "images": [_image_json(i) for i in images],
            "next_cursor": None,
            "date_groups": [],
        }
    )


@gallery_bp.route("/timeline", methods=["GET"])
async def timeline():
    try:
        filters, _sort = _parse_filters()
    except ValueError:
        return jsonify({"error": "invalid filter parameter"}), 400
    async with get_session() as session:
        svc = GalleryService(session)
        try:
            buckets = await svc.timeline(**filters)
        except ValueError as exc:
            return jsonify({"error": str(exc)}), 400
    return jsonify(
        [{"year": b.year, "month": b.month, "count": b.count} for b in buckets]
    )


@gallery_bp.route("/facets", methods=["GET"])
async def facets():
    try:
        filters, _sort = _parse_filters()
    except ValueError:
        return jsonify({"error": "invalid filter parameter"}), 400
    async with get_session() as session:
        svc = GalleryService(session)
        try:
            f = await svc.facets(**filters)
        except ValueError as exc:
            return jsonify({"error": str(exc)}), 400
    return jsonify(
        {
            "total": f.total,
            "platforms": f.platforms,
            "untagged": f.untagged,
            "no_artist": f.no_artist,
            "date_min": f.date_min.isoformat() if f.date_min else None,
            "date_max": f.date_max.isoformat() if f.date_max else None,
        }
    )


@gallery_bp.route("/jump", methods=["GET"])
async def jump():
    try:
        year = int(request.args["year"])
        month = int(request.args["month"])
        filters, sort = _parse_filters()
    except (KeyError, ValueError):
        return jsonify({"error": "year and month query params required"}), 400
    async with get_session() as session:
        svc = GalleryService(session)
        try:
            cursor = await svc.jump_cursor(
                year=year, month=month, sort=sort, **filters,
            )
        except ValueError as exc:
            return jsonify({"error": str(exc)}), 400
    return jsonify({"cursor": cursor})


# -- Hidden-view review (#141): auto-hidden chrome flagged "also looks like
#    content", surfaced in the gallery's Show-hidden review strip. -----------
@gallery_bp.route("/hidden-review", methods=["GET"])
async def hidden_review():
    """Unresolved system-tag auto-apply review flags (chrome + process, #1464),
    most-concerning first (highest content score) — for the review strip. `mode`
    tells the client whether the flagged tag hid the image ('chrome') or left it
    visible ('process'), which decides the resolve labels (un-hide vs remove-tag)."""
    ptag = aliased(Tag)
    ctag = aliased(Tag)
    async with get_session() as session:
        rows = (await session.execute(
            select(
                PresentationReview.image_record_id,
                PresentationReview.tag_id,
                PresentationReview.conflict_tag_id,
                PresentationReview.conflict_score,
                PresentationReview.mode,
                ImageRecord.path, ImageRecord.thumbnail_path,
                ImageRecord.sha256, ImageRecord.mime,
                ptag.name.label("tag_name"),
                ctag.name.label("conflict_name"),
            )
            .join(ImageRecord, ImageRecord.id == PresentationReview.image_record_id)
            .join(ptag, ptag.id == PresentationReview.tag_id)
            .outerjoin(ctag, ctag.id == PresentationReview.conflict_tag_id)
            .where(PresentationReview.resolved_at.is_(None))
            .order_by(PresentationReview.conflict_score.desc())
        )).all()
    return jsonify({"items": [
        {
            "image_id": r.image_record_id,
            "tag_id": r.tag_id,
            "tag_name": r.tag_name,
            "conflict_tag_id": r.conflict_tag_id,
            "conflict_name": r.conflict_name,
            "conflict_score": r.conflict_score,
            "mode": r.mode,
            "thumbnail_url": thumbnail_url(r.thumbnail_path, r.sha256, r.mime),
            "image_url": image_url(r.path),
        }
        for r in rows
    ]})


@gallery_bp.route(
    "/hidden-review/<int:image_id>/<int:tag_id>/keep", methods=["POST"]
)
async def hidden_review_keep(image_id, tag_id):
    """Keep the auto-hide: resolve the flag; the tag stays applied (#141)."""
    async with get_session() as session:
        await session.execute(
            update(PresentationReview)
            .where(
                PresentationReview.image_record_id == image_id,
                PresentationReview.tag_id == tag_id,
            )
            .values(resolved_at=datetime.now(UTC))
        )
        await session.commit()
    return "", 204


@gallery_bp.route(
    "/hidden-review/<int:image_id>/<int:tag_id>/unhide", methods=["POST"]
)
async def hidden_review_unhide(image_id, tag_id):
    """Un-hide: remove the presentation tag (image returns to the gallery), record
    a rejection so the head LEARNS it misfired, and resolve the flag (#141)."""
    from sqlalchemy.dialects.postgresql import insert as pg_insert

    async with get_session() as session:
        await session.execute(
            delete(image_tag).where(
                image_tag.c.image_record_id == image_id,
                image_tag.c.tag_id == tag_id,
            )
        )
        await session.execute(
            pg_insert(TagSuggestionRejection)
            .values(image_record_id=image_id, tag_id=tag_id)
            .on_conflict_do_nothing()
        )
        await session.execute(
            update(PresentationReview)
            .where(
                PresentationReview.image_record_id == image_id,
                PresentationReview.tag_id == tag_id,
            )
            .values(resolved_at=datetime.now(UTC))
        )
        await session.commit()
    return "", 204


@gallery_bp.route("/image/<int:image_id>", methods=["GET"])
async def image_detail(image_id: int):
    async with get_session() as session:
        svc = GalleryService(session)
        payload = await svc.get_image_with_tags(image_id)
    if payload is None:
        return jsonify({"error": "not found"}), 404
    return jsonify(payload)
