"""Tags API: autocomplete, create, list/add/remove for an image."""

from quart import Blueprint, jsonify, request
from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.exc import IntegrityError

from ..extensions import get_session
from ..models import Tag, TagHead, TagKind, TagPositiveConfirmation
from ..models.tag import image_tag
from ..models.tag_suggestion_rejection import TagSuggestionRejection
from ..services.bulk_tag_service import BulkTagService
from ..services.ml.aliases import AliasService
from ..services.ml.heads import ground_applied_tag
from ..services.series_match_service import SeriesMatchService
from ..services.series_service import SeriesError, SeriesService
from ..services.tag_directory_service import TagDirectoryService
from ..services.tag_query import serialize_tag
from ..services.tag_service import (
    TagMergeConflict,
    TagService,
    TagValidationError,
    normalize_tag_name,
)
from ..utils.tag_prefix import parse_kind_prefix

tags_bp = Blueprint("tags", __name__, url_prefix="/api")


def _coerce_kind(raw: str | None) -> TagKind | None:
    if raw is None:
        return None
    try:
        return TagKind(raw)
    except ValueError:
        return None


def _parse_bulk_ids(
    body, *, max_ids: int = 200
) -> tuple[list[int] | None, tuple | None]:
    """Returns (image_ids, error). error is (json, status) or None."""
    if not body or "image_ids" not in body:
        return None, (jsonify({"error": "image_ids required"}), 400)
    raw = body["image_ids"]
    if not isinstance(raw, list) or not raw:
        return None, (
            jsonify({"error": "image_ids must be a non-empty list"}),
            400,
        )
    try:
        ids = [int(x) for x in raw]
    except (TypeError, ValueError):
        return None, (
            jsonify({"error": "image_ids must be integers"}),
            400,
        )
    if len(ids) > max_ids:
        return None, (
            jsonify({"error": f"selection too large (max {max_ids})"}),
            400,
        )
    return ids, None


# Application-source groupings (image_tag.source). HUMAN = operator signal;
# AUTO = machine-applied (heads/CCIP, + legacy Camie ml_auto).
_SOURCE_GROUPS = {
    "human": ("manual", "ml_accepted"),
    "manual": ("manual",),
    "accepted": ("ml_accepted",),
    "auto": ("head_auto", "ccip_auto", "ml_auto"),
}


@tags_bp.route("/tags/top", methods=["GET"])
async def tags_top():
    """Top tags by image count — a fast indexed aggregate for ANALYSIS (not the
    paged UI directory, which is alphabetical + builds previews). Params:
    ?kind=general|character|fandom|… ?source=all|human|manual|accepted|auto
    ?limit=50 (cap 500) ?min_count=N. → {tags:[{tag_id,name,kind,count}]} desc."""
    kind = _coerce_kind(request.args.get("kind"))
    try:
        limit = min(max(int(request.args.get("limit", "50")), 1), 500)
    except ValueError:
        return jsonify({"error": "limit must be an integer"}), 400
    min_count = None
    if "min_count" in request.args:
        try:
            min_count = int(request.args["min_count"])
        except ValueError:
            return jsonify({"error": "min_count must be an integer"}), 400
    src_vals = _SOURCE_GROUPS.get((request.args.get("source") or "all").lower())

    cnt = func.count(image_tag.c.image_record_id)
    stmt = (
        select(Tag.id, Tag.name, Tag.kind, cnt.label("count"))
        .select_from(Tag)
        .join(image_tag, image_tag.c.tag_id == Tag.id)
        .group_by(Tag.id, Tag.name, Tag.kind)
        .order_by(cnt.desc(), Tag.name.asc())
        .limit(limit)
    )
    if kind is not None:
        stmt = stmt.where(Tag.kind == kind)
    if src_vals is not None:
        stmt = stmt.where(image_tag.c.source.in_(src_vals))
    if min_count is not None:
        stmt = stmt.having(cnt >= min_count)
    async with get_session() as session:
        rows = (await session.execute(stmt)).all()
    return jsonify({"tags": [
        {
            "tag_id": r.id, "name": r.name,
            "kind": r.kind.value if hasattr(r.kind, "value") else str(r.kind),
            "count": r.count,
        }
        for r in rows
    ]})


@tags_bp.route("/tags/<int:tag_id>/stats", methods=["GET"])
async def tag_stats(tag_id: int):
    """Per-tag dataset health: total + per-source application counts (human vs
    machine), rejection count, and whether a trained head exists. Read-only,
    analysis-shaped — backs concept-readiness + source-split decisions."""
    async with get_session() as session:
        tag = await session.get(Tag, tag_id)
        if tag is None:
            return jsonify({"error": "not found"}), 404
        by_source = dict(
            (
                await session.execute(
                    select(image_tag.c.source, func.count())
                    .where(image_tag.c.tag_id == tag_id)
                    .group_by(image_tag.c.source)
                )
            ).all()
        )
        rejected = (
            await session.execute(
                select(func.count())
                .select_from(TagSuggestionRejection)
                .where(TagSuggestionRejection.tag_id == tag_id)
            )
        ).scalar_one()
        has_head = (
            await session.execute(
                select(func.count())
                .select_from(TagHead)
                .where(TagHead.tag_id == tag_id)
            )
        ).scalar_one() > 0
    human = by_source.get("manual", 0) + by_source.get("ml_accepted", 0)
    auto = (
        by_source.get("head_auto", 0)
        + by_source.get("ccip_auto", 0)
        + by_source.get("ml_auto", 0)
    )
    return jsonify({
        "tag_id": tag_id,
        "name": tag.name,
        "kind": tag.kind.value if hasattr(tag.kind, "value") else str(tag.kind),
        "count_total": sum(by_source.values()),
        "count_human": human,
        "count_manual": by_source.get("manual", 0),
        "count_accepted": by_source.get("ml_accepted", 0),
        "count_auto": auto,
        "count_head_auto": by_source.get("head_auto", 0),
        "count_ccip_auto": by_source.get("ccip_auto", 0),
        "count_rejected": rejected,
        "by_source": by_source,
        "has_head": has_head,
    })


@tags_bp.route("/tags/autocomplete", methods=["GET"])
async def autocomplete():
    q = request.args.get("q", "")
    kind = _coerce_kind(request.args.get("kind"))
    try:
        limit = int(request.args.get("limit", "20"))
    except ValueError:
        return jsonify({"error": "limit must be an integer"}), 400

    async with get_session() as session:
        svc = TagService(session)
        hits = await svc.autocomplete(q, kind=kind, limit=limit)

    return jsonify(
        [{**serialize_tag(h), "image_count": h.image_count} for h in hits]
    )


@tags_bp.route("/tags/directory", methods=["GET"])
async def directory():
    kind = request.args.get("kind") or None
    if kind is not None and _coerce_kind(kind) is None:
        return jsonify({"error": f"invalid kind {kind!r}"}), 400
    q = request.args.get("q") or None
    cursor = request.args.get("cursor") or None
    try:
        limit = int(request.args.get("limit", "60"))
    except ValueError:
        return jsonify({"error": "limit must be an integer"}), 400
    async with get_session() as session:
        svc = TagDirectoryService(session)
        try:
            page = await svc.list_tags(kind=kind, q=q, cursor=cursor, limit=limit)
        except ValueError as exc:
            return jsonify({"error": str(exc)}), 400
    return jsonify({"cards": page.cards, "next_cursor": page.next_cursor})


@tags_bp.route("/tags", methods=["POST"])
async def create_tag():
    """Create a tag. Two input shapes accepted:
      1. Explicit: {name, kind, fandom_id?} — caller already split, kind wins.
      2. IR-suffix: {name} where name = "kind:Name" (e.g. "artist:Eric").
         The server runs parse_kind_prefix(name) to derive kind; the colon
         and prefix are stripped from the stored tag name. If no recognized
         prefix is present, the kind defaults to `general`.
    Explicit kind ALWAYS wins (backward-compat for existing callers).
    """
    body = await request.get_json()
    if not body or "name" not in body:
        return jsonify({"error": "name required"}), 400
    name = body["name"]
    explicit_kind_raw = body.get("kind")

    if explicit_kind_raw is not None:
        # Caller provided kind — honor it; don't re-parse.
        kind = _coerce_kind(explicit_kind_raw)
        if kind is None:
            return jsonify({"error": f"invalid kind {explicit_kind_raw!r}"}), 400
    else:
        # IR-style: parse "kind:Name" from the raw name.
        parsed_kind, parsed_name = parse_kind_prefix(name)
        if parsed_kind is not None:
            name = parsed_name
            kind = _coerce_kind(parsed_kind)
            # parse_kind_prefix only returns kinds from KNOWN_KINDS which
            # are all valid TagKind members, so _coerce_kind can't return
            # None here — but defensive.
            if kind is None:
                return jsonify({"error": f"invalid kind {parsed_kind!r}"}), 400
        else:
            kind = TagKind.general

    fandom_id = body.get("fandom_id")

    # #701: Title-Case operator-entered tags. Only here (the explicit create
    # endpoint), NOT in the shared find_or_create — the ML tagger uses that path
    # and must keep the booru vocabulary's casing for allowlist matching.
    name = normalize_tag_name(name)

    async with get_session() as session:
        svc = TagService(session)
        try:
            tag = await svc.find_or_create(name, kind, fandom_id=fandom_id)
        except TagValidationError as exc:
            return jsonify({"error": str(exc)}), 400
        await session.commit()
        return jsonify(
            {"id": tag.id, "name": tag.name, "kind": tag.kind.value, "fandom_id": tag.fandom_id}
        ), 201


@tags_bp.route("/images/<int:image_id>/tags", methods=["GET"])
async def list_tags_for_image(image_id: int):
    async with get_session() as session:
        svc = TagService(session)
        tags = await svc.list_for_image(image_id)
    return jsonify([serialize_tag(t) for t in tags])


@tags_bp.route("/images/<int:image_id>/tags", methods=["POST"])
async def add_tag_to_image(image_id: int):
    body = await request.get_json()
    if not body or "tag_id" not in body:
        return jsonify({"error": "tag_id required"}), 400
    source = body.get("source", "manual")

    async with get_session() as session:
        svc = TagService(session)
        await svc.add_to_image(image_id, body["tag_id"], source=source)
        await session.commit()
    return "", 204


@tags_bp.route("/images/<int:image_id>/tags/<int:tag_id>", methods=["DELETE"])
async def remove_tag_from_image(image_id: int, tag_id: int):
    async with get_session() as session:
        svc = TagService(session)
        await svc.remove_from_image(image_id, tag_id)
        await session.commit()
    return "", 204


@tags_bp.route("/images/<int:image_id>/tags/<int:tag_id>/confirm", methods=["POST"])
async def confirm_tag_on_image(image_id: int, tag_id: int):
    """Operator affirmed an applied tag is correct ("keep" on a doubted positive).
    Idempotent; recorded so the eval's doubts list stops resurfacing it (#1130)."""
    async with get_session() as session:
        await session.execute(
            pg_insert(TagPositiveConfirmation)
            .values(image_record_id=image_id, tag_id=tag_id)
            .on_conflict_do_nothing(index_elements=["image_record_id", "tag_id"])
        )
        await session.commit()
    return "", 204


@tags_bp.route(
    "/images/<int:image_id>/tags/<int:tag_id>/grounding", methods=["GET"]
)
async def tag_grounding(image_id: int, tag_id: int):
    """Which crop region best explains an ALREADY-APPLIED tag on this image
    (#1206 Step 4). Powers the hover→overlay highlight on applied tag chips,
    mirroring the suggestion rail's live grounding. Computed on demand (applied
    tags aren't scored live). → {grounding: {bbox,kind,detector}|null,
    has_head: bool}; has_head False means the tag has no head to localize with,
    so the chip shows no overlay."""
    async with get_session() as session:
        grounding, has_head = await ground_applied_tag(session, image_id, tag_id)
    return jsonify({"grounding": grounding, "has_head": has_head})


@tags_bp.route("/tags/<int:tag_id>", methods=["GET"])
async def get_tag(tag_id: int):
    """Resolve a single tag (used by the gallery to label its active
    tag-filter chip)."""
    async with get_session() as session:
        tag = await session.get(Tag, tag_id)
        if tag is None:
            return jsonify({"error": "tag not found"}), 404
        return jsonify(
            {
                "id": tag.id,
                "name": tag.name,
                "kind": tag.kind.value,
                "fandom_id": tag.fandom_id,
                "is_system": tag.is_system,
            }
        )


@tags_bp.route("/tags/<int:tag_id>/aliases", methods=["GET"])
async def list_tag_aliases(tag_id: int):
    """Model keys that fold into this tag (tag-side alias view). Remove via the
    shared DELETE /api/aliases/<string>/<category>."""
    async with get_session() as session:
        if await session.get(Tag, tag_id) is None:
            return jsonify({"error": "tag not found"}), 404
        rows = await AliasService(session).list_for_tag(tag_id)
    return jsonify(
        [
            {
                "alias_string": r.alias_string,
                "alias_category": r.alias_category,
            }
            for r in rows
        ]
    )


@tags_bp.route("/tags/<int:tag_id>", methods=["PATCH"])
async def update_tag(tag_id: int):
    """Rename and/or re-fandom a tag. Body may carry `name` and/or
    `fandom_id` (a fandom tag id, or null to clear — character tags only).
    `merge: true` resolves a collision by merging into the existing tag.
    """
    body = await request.get_json() or {}
    has_name = "name" in body
    has_fandom = "fandom_id" in body
    if not has_name and not has_fandom:
        return jsonify({"error": "name or fandom_id required"}), 400
    do_merge = bool(body.get("merge"))
    async with get_session() as session:
        svc = TagService(session)
        try:
            tag = None
            if has_name:
                tag = await svc.rename(tag_id, body["name"])
            if has_fandom:
                tag = await svc.set_fandom(
                    tag_id, body["fandom_id"], merge=do_merge
                )
        except TagMergeConflict as exc:
            return jsonify(
                {
                    "error": str(exc),
                    "target": {
                        "id": exc.target_id,
                        "name": exc.target_name,
                    },
                    "source_image_count": exc.source_image_count,
                    "will_alias": exc.will_alias,
                }
            ), 409
        except TagValidationError as exc:
            return jsonify({"error": str(exc)}), 400
        await session.commit()
        return jsonify(
            {
                "id": tag.id,
                "name": tag.name,
                "kind": tag.kind.value,
                "fandom_id": tag.fandom_id,
                "is_system": tag.is_system,
            }
        )


@tags_bp.route("/tags/<int:source_id>/merge", methods=["POST"])
async def merge_tag(source_id: int):
    body = await request.get_json()
    if not body or "target_id" not in body:
        return jsonify({"error": "target_id required"}), 400
    target_id = body["target_id"]
    async with get_session() as session:
        svc = TagService(session)
        try:
            result = await svc.merge(source_id, target_id)
        except TagValidationError as exc:
            msg = str(exc)
            status = 404 if "not found" in msg else 400
            return jsonify({"error": msg}), status
        await session.commit()
    return jsonify(
        {
            "target": {
                "id": result.target_id,
                "name": result.target_name,
                "kind": result.target_kind,
            },
            "merged_count": result.merged_count,
            "alias_created": result.alias_created,
            "source_deleted": result.source_deleted,
        }
    )


@tags_bp.route("/images/common-tags", methods=["POST"])
async def common_tags():
    body = await request.get_json()
    ids, err = _parse_bulk_ids(body)
    if err:
        return err
    async with get_session() as session:
        tags = await BulkTagService(session).common_tags(ids)
    return jsonify({"tags": tags})


@tags_bp.route("/images/bulk/tags", methods=["POST"])
async def bulk_add_tag():
    body = await request.get_json()
    ids, err = _parse_bulk_ids(body)
    if err:
        return err
    if not body or "tag_id" not in body:
        return jsonify({"error": "tag_id required"}), 400
    source = body.get("source", "manual")
    if source not in ("manual", "ml_accepted"):
        return jsonify({"error": f"invalid source {source!r}"}), 400
    async with get_session() as session:
        if await session.get(Tag, body["tag_id"]) is None:
            return jsonify({"error": "tag not found"}), 404
        try:
            added = await BulkTagService(session).bulk_add(
                ids, body["tag_id"], source=source
            )
        except IntegrityError:
            return jsonify(
                {"error": "one or more image_ids do not exist"}
            ), 400
        await session.commit()
    return jsonify({"added_count": added, "total": len(ids)})


@tags_bp.route("/images/bulk/tags/remove", methods=["POST"])
async def bulk_remove_tag():
    body = await request.get_json()
    ids, err = _parse_bulk_ids(body)
    if err:
        return err
    if not body or "tag_id" not in body:
        return jsonify({"error": "tag_id required"}), 400
    async with get_session() as session:
        if await session.get(Tag, body["tag_id"]) is None:
            return jsonify({"error": "tag not found"}), 404
        removed = await BulkTagService(session).bulk_remove(
            ids, body["tag_id"]
        )
        await session.commit()
    return jsonify({"removed_count": removed})


def _series_err(exc: SeriesError):
    msg = str(exc)
    status = 404 if ("not found" in msg or "not a series" in msg) else 400
    return jsonify({"error": msg}), status


def _opt_int(body, key: str):
    """(value, error) — value is None when absent, error is (json, status)."""
    if not body or body.get(key) is None:
        return None, None
    try:
        return int(body[key]), None
    except (TypeError, ValueError):
        return None, (jsonify({"error": f"{key} must be an integer"}), 400)


def _parse_int_list(body, key: str, *, max_ids: int = 500):
    """(list, error) for a required list of ints under `key`."""
    if not body or key not in body:
        return None, (jsonify({"error": f"{key} required"}), 400)
    raw = body[key]
    if not isinstance(raw, list) or not raw:
        return None, (jsonify({"error": f"{key} must be a non-empty list"}), 400)
    if len(raw) > max_ids:
        return None, (jsonify({"error": f"too many ids (max {max_ids})"}), 400)
    try:
        return [int(x) for x in raw], None
    except (TypeError, ValueError):
        return None, (jsonify({"error": f"{key} must be integers"}), 400)


@tags_bp.route("/series/<int:tag_id>/pages", methods=["GET"])
async def series_pages(tag_id: int):
    async with get_session() as session:
        try:
            data = await SeriesService(session).list_pages(tag_id)
        except SeriesError as exc:
            return _series_err(exc)
    return jsonify(data)


@tags_bp.route("/series/<int:tag_id>/pages", methods=["POST"])
async def series_add(tag_id: int):
    body = await request.get_json()
    ids, err = _parse_bulk_ids(body, max_ids=500)
    if err:
        return err
    async with get_session() as session:
        try:
            n = await SeriesService(session).add_images(tag_id, ids)
        except SeriesError as exc:
            return _series_err(exc)
        await session.commit()
    return jsonify({"added_count": n})


@tags_bp.route("/series/<int:tag_id>/pages/remove", methods=["POST"])
async def series_remove(tag_id: int):
    body = await request.get_json()
    ids, err = _parse_bulk_ids(body, max_ids=500)
    if err:
        return err
    async with get_session() as session:
        try:
            n = await SeriesService(session).remove_images(tag_id, ids)
        except SeriesError as exc:
            return _series_err(exc)
        await session.commit()
    return jsonify({"removed_count": n})


@tags_bp.route("/series/<int:tag_id>/pages/number", methods=["POST"])
async def series_set_page_number(tag_id: int):
    """Set one placed page's number — the operator's value (sparse, gaps
    allowed); pass page_number: null to leave it unnumbered."""
    body = await request.get_json() or {}
    image_id, ierr = _opt_int(body, "image_id")
    if ierr:
        return ierr
    if image_id is None:
        return jsonify({"error": "image_id required"}), 400
    if "page_number" not in body:
        return jsonify({"error": "page_number required (may be null)"}), 400
    page_number, perr = _opt_int(body, "page_number")
    if perr:
        return perr
    async with get_session() as session:
        try:
            await SeriesService(session).set_page_number(
                tag_id, image_id, page_number
            )
        except SeriesError as exc:
            return _series_err(exc)
        await session.commit()
    return jsonify({"ok": True})


@tags_bp.route("/series/<int:tag_id>/cover", methods=["POST"])
async def series_cover(tag_id: int):
    body = await request.get_json()
    if not body or "image_id" not in body:
        return jsonify({"error": "image_id required"}), 400
    try:
        image_id = int(body["image_id"])
    except (TypeError, ValueError):
        return jsonify({"error": "image_id must be an integer"}), 400
    async with get_session() as session:
        try:
            await SeriesService(session).set_cover(tag_id, image_id)
        except SeriesError as exc:
            return _series_err(exc)
        await session.commit()
    return jsonify({"ok": True})


# ---- chapter dividers (FC-6.x) -------------------------------------------
# A chapter is a cosmetic divider anchored to the page that begins it; it owns
# no pages. Page ordering follows each page's operator-set number (the
# /pages/number endpoint), so there is no per-chapter reorder/merge — those are
# gone.


@tags_bp.route("/series/<int:tag_id>/chapters", methods=["POST"])
async def series_chapter_create(tag_id: int):
    body = await request.get_json() or {}
    anchor, aerr = _opt_int(body, "anchor_image_id")
    if aerr:
        return aerr
    if anchor is None:
        return jsonify({"error": "anchor_image_id required"}), 400
    title = body.get("title")
    if title is not None and not isinstance(title, str):
        return jsonify({"error": "title must be a string"}), 400
    part, perr = _opt_int(body, "stated_part")
    if perr:
        return perr
    async with get_session() as session:
        try:
            ch = await SeriesService(session).create_divider(
                tag_id, anchor, title=title, stated_part=part,
            )
        except SeriesError as exc:
            return _series_err(exc)
        await session.commit()
    return jsonify(ch)


@tags_bp.route(
    "/series/<int:tag_id>/chapters/<int:chapter_id>", methods=["PATCH"]
)
async def series_chapter_update(tag_id: int, chapter_id: int):
    body = await request.get_json() or {}
    kwargs: dict = {}
    if "title" in body:
        if body["title"] is not None and not isinstance(body["title"], str):
            return jsonify({"error": "title must be a string"}), 400
        kwargs.update(set_title=True, title=body["title"])
    if "stated_part" in body:
        part, perr = _opt_int(body, "stated_part")
        if perr:
            return perr
        kwargs.update(set_part=True, stated_part=part)
    if "anchor_image_id" in body:
        anchor, aerr = _opt_int(body, "anchor_image_id")
        if aerr:
            return aerr
        if anchor is None:
            return jsonify(
                {"error": "anchor_image_id must be an integer"}
            ), 400
        kwargs.update(set_anchor=True, anchor_image_id=anchor)
    async with get_session() as session:
        try:
            await SeriesService(session).update_divider(
                tag_id, chapter_id, **kwargs
            )
        except SeriesError as exc:
            return _series_err(exc)
        await session.commit()
    return jsonify({"ok": True})


@tags_bp.route(
    "/series/<int:tag_id>/chapters/<int:chapter_id>", methods=["DELETE"]
)
async def series_chapter_delete(tag_id: int, chapter_id: int):
    async with get_session() as session:
        try:
            await SeriesService(session).delete_divider(tag_id, chapter_id)
        except SeriesError as exc:
            return _series_err(exc)
        await session.commit()
    return jsonify({"ok": True})


# ---- browse list + post→series flows (FC-6.2) -----------------------------


@tags_bp.route("/series", methods=["GET"])
async def series_list():
    args = request.args
    sort = args.get("sort", "recent")
    if sort not in ("recent", "name", "size"):
        return jsonify({"error": "sort must be recent|name|size"}), 400
    artist_id = None
    if args.get("artist_id") is not None:
        try:
            artist_id = int(args["artist_id"])
        except ValueError:
            return jsonify({"error": "artist_id must be an integer"}), 400
    async with get_session() as session:
        rows = await SeriesService(session).list_series(
            sort=sort, artist_id=artist_id
        )
    return jsonify({"series": rows})


@tags_bp.route("/series/from-post", methods=["POST"])
async def series_from_post():
    body = await request.get_json()
    post_id, err = _opt_int(body, "post_id")
    if err:
        return err
    if post_id is None:
        return jsonify({"error": "post_id required"}), 400
    async with get_session() as session:
        try:
            out = await SeriesService(session).promote_post_to_series(post_id)
        except SeriesError as exc:
            return _series_err(exc)
        await session.commit()
    return jsonify(out)


@tags_bp.route("/series/<int:tag_id>/add-post", methods=["POST"])
async def series_add_post(tag_id: int):
    body = await request.get_json()
    post_id, err = _opt_int(body, "post_id")
    if err:
        return err
    if post_id is None:
        return jsonify({"error": "post_id required"}), 400
    async with get_session() as session:
        try:
            out = await SeriesService(session).add_post(tag_id, post_id)
        except SeriesError as exc:
            return _series_err(exc)
        await session.commit()
    return jsonify(out)


@tags_bp.route("/series/<int:tag_id>/pending/place", methods=["POST"])
async def series_place_pending(tag_id: int):
    """Place staged (pending) pages into the run, numbered sequentially from
    `start_page` in the given order (#789). start_page null → unnumbered."""
    body = await request.get_json()
    ids, err = _parse_bulk_ids(body, max_ids=500)
    if err:
        return err
    start, serr = _opt_int(body, "start_page")
    if serr:
        return serr
    async with get_session() as session:
        try:
            n = await SeriesService(session).place_pending(
                tag_id, ids, start_page=start
            )
        except SeriesError as exc:
            return _series_err(exc)
        await session.commit()
    return jsonify({"placed_count": n})


# ---- suggestion queue (FC-6.3) --------------------------------------------


@tags_bp.route("/series/suggestions", methods=["GET"])
async def series_suggestions_list():
    async with get_session() as session:
        rows = await SeriesMatchService(session).list_pending()
    return jsonify({"suggestions": rows})


@tags_bp.route("/series/suggestions/<int:sid>/accept", methods=["POST"])
async def series_suggestion_accept(sid: int):
    async with get_session() as session:
        try:
            out = await SeriesMatchService(session).accept(sid)
        except SeriesError as exc:
            return _series_err(exc)
        await session.commit()
    return jsonify(out)


@tags_bp.route("/series/suggestions/<int:sid>/dismiss", methods=["POST"])
async def series_suggestion_dismiss(sid: int):
    async with get_session() as session:
        try:
            await SeriesMatchService(session).dismiss(sid)
        except SeriesError as exc:
            return _series_err(exc)
        await session.commit()
    return jsonify({"ok": True})


@tags_bp.route("/series/suggestions/rescan", methods=["POST"])
async def series_suggestions_rescan():
    from ..tasks.admin import rescan_series_suggestions_task

    res = rescan_series_suggestions_task.delay()
    return jsonify({"task_id": res.id})
