"""API endpoints for the Curated queue interface."""

from __future__ import annotations

import html
import random
import re
from datetime import UTC
from datetime import datetime
from datetime import timedelta

from sqlalchemy import select
from sqlalchemy.orm import Session
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.responses import Response
from starlette.routing import Route

from fenliu.database import get_db
from fenliu.models import Post
from fenliu.models import QueueStatus
from fenliu.schemas import CuratedErrorRequest
from fenliu.schemas import CuratedNextResponse
from fenliu.services.export_eligibility import check_reblog_filters
from fenliu.services.post_storage import cleanup_delivered_posts
from fenliu.services.post_storage import trim_pending_posts

# How long a reserved post may sit before being returned to the queue.
RESERVE_TIMEOUT_SECONDS = 300  # 5 minutes


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------


def _strip_html(text: str) -> str:
    """Remove HTML tags and decode entities, returning plain text."""
    no_tags = re.sub(r"<[^>]+>", " ", text)
    decoded = html.unescape(no_tags)
    # Collapse whitespace
    return re.sub(r"\s+", " ", decoded).strip()


def _content_snippet(content: str, length: int = 280) -> str:
    """Return the first *length* characters of plain-text post content."""
    plain = _strip_html(content)
    if len(plain) <= length:
        return plain
    return plain[:length]


def _author_account(post: Post) -> str:
    """Build a @user@instance.social style account identifier."""
    username = (post.author_username or "unknown").strip().lstrip("@")
    instance = (post.instance or "").strip()
    if instance:
        return f"@{username}@{instance}"
    return f"@{username}"


def _sweep_timed_out(db: Session) -> None:
    """Return stale reserved posts to *pending* status.

    The comparison is done in Python rather than SQL to avoid SQLite's
    string-based datetime comparison behaving unexpectedly with
    timezone-aware values.

    A ``db.flush()`` is called at the end so the status changes are
    visible to subsequent SELECT statements within the same transaction
    (the session uses ``autoflush=False``).
    """
    cutoff = datetime.now(UTC) - timedelta(seconds=RESERVE_TIMEOUT_SECONDS)
    stmt = select(Post).where(Post.queue_status == QueueStatus.RESERVED)
    reserved = list(db.execute(stmt).scalars().all())
    swept = False
    for post in reserved:
        if post.reserved_at is None:
            continue
        ra = post.reserved_at
        # Normalise to UTC-aware for comparison
        if ra.tzinfo is None:
            ra = ra.replace(tzinfo=UTC)
        if ra <= cutoff:
            post.queue_status = QueueStatus.PENDING
            post.reserved_at = None
            swept = True
    if swept:
        db.flush()


def _post_to_response(post: Post) -> CuratedNextResponse:
    """Convert a Post ORM object to a CuratedNextResponse."""
    hashtags: list[str] = []
    if isinstance(post.hashtags, list):
        hashtags = [str(t).lstrip("#") for t in post.hashtags]

    stream_hashtag: str | None = None
    if post.stream is not None:
        stream_hashtag = post.stream.hashtag

    has_attachments = False
    if isinstance(post.media_attachments, list):
        has_attachments = len(post.media_attachments) > 0

    return CuratedNextResponse(
        fenliu_post_id=post.id,
        post_uri=post.post_id,
        post_url=post.url,
        author_account=_author_account(post),
        content_snippet=_content_snippet(post.content),
        hashtags=hashtags,
        stream=stream_hashtag,
        has_attachments=has_attachments,
        spam_score=post.spam_score,
        post_created_at=post.created_at,
        approved_at=post.reviewed_at,
    )


# ---------------------------------------------------------------------------
# Endpoint handlers
# ---------------------------------------------------------------------------


def _select_fifo(candidates: list[Post], db: Session) -> Post | None:
    """Return the oldest eligible post (FIFO order)."""
    for post in candidates:
        _stream = post.stream  # eagerly load before eligibility check
        if check_reblog_filters(post, db).eligible:
            return post
    return None


def _oldest_author_post(candidate: Post, db: Session) -> Post:
    """Return the oldest pending post for candidate's author.

    If the author has only one pending post, returns candidate unchanged.
    """
    stmt = (
        select(Post)
        .where(
            Post.queue_status == QueueStatus.PENDING,
            Post.author_username == candidate.author_username,
            Post.instance == candidate.instance,
        )
        .order_by(Post.reviewed_at.asc().nullslast(), Post.fetched_at.asc())
    )
    author_posts = list(db.execute(stmt).scalars().all())
    return author_posts[0] if len(author_posts) > 1 else candidate


def _select_random(candidates: list[Post], db: Session) -> Post | None:
    """Return a randomly chosen eligible post, preferring the author's oldest."""
    eligible = [post for post in candidates if (_load_stream(post), check_reblog_filters(post, db))[1].eligible]
    if not eligible:
        return None
    return _oldest_author_post(random.choice(eligible), db)  # noqa: S311


def _load_stream(post: Post) -> None:
    """Eagerly access the stream relationship (side-effect load)."""
    _ = post.stream


async def get_next(request: Request) -> Response:
    """Reserve and return the next pending post for Curated.

    Returns 200 with a JSON post object, or 204 when the queue is empty.
    Before selecting a post the endpoint sweeps for timed-out reservations
    and returns them to *pending* so they are re-presented.

    Query parameters:
        random: When "true", "1", or "yes", select a random eligible post
            instead of the oldest one. If the randomly chosen author has more
            than one pending post, the oldest post for that author is returned
            instead to avoid over-representing any single author.
    """
    use_random = request.query_params.get("random", "false").lower() in ("true", "1", "yes")

    with get_db() as db:
        # 1. Return timed-out reservations to the queue.
        _sweep_timed_out(db)

        # 2. Find pending posts that pass reblog filters.
        stmt = select(Post).where(Post.queue_status == QueueStatus.PENDING).order_by(Post.reviewed_at.asc().nullslast())
        candidates = list(db.execute(stmt).scalars().all())

        selected = _select_random(candidates, db) if use_random else _select_fifo(candidates, db)

        if selected is None:
            return Response(status_code=204)

        # 3. Reserve the post.
        selected.queue_status = QueueStatus.RESERVED
        selected.reserved_at = datetime.now(UTC)
        db.commit()
        db.refresh(selected)
        _ = selected.stream  # reload stream relationship after commit

        response_data = _post_to_response(selected)
        return JSONResponse(response_data.model_dump(mode="json"))


async def ack_post(request: Request) -> Response:
    """Mark a reserved post as successfully delivered.

    Curated calls this after it has successfully reblogged the post.
    Transitions the post from *reserved* to *delivered*.
    """
    try:
        post_id = int(request.path_params["post_id"])
    except (KeyError, ValueError):
        return JSONResponse({"detail": "Invalid post ID"}, status_code=400)

    with get_db() as db:
        post = db.get(Post, post_id)
        if post is None:
            return JSONResponse({"detail": f"Post {post_id} not found"}, status_code=404)

        if post.queue_status != QueueStatus.RESERVED:
            return JSONResponse(
                {"detail": f"Post is in state '{post.queue_status}', expected 'reserved'"},
                status_code=409,
            )

        post.queue_status = QueueStatus.DELIVERED
        post.delivered_at = datetime.now(UTC)
        post.reserved_at = None
        db.commit()

    return Response(status_code=204)


async def nack_post(request: Request) -> Response:
    """Return a reserved post to the front of the queue.

    Curated calls this on a transient failure (network error, rate limit,
    restart).  The post is returned to *pending* and will be re-presented on
    the next /next call.
    """
    try:
        post_id = int(request.path_params["post_id"])
    except (KeyError, ValueError):
        return JSONResponse({"detail": "Invalid post ID"}, status_code=400)

    with get_db() as db:
        post = db.get(Post, post_id)
        if post is None:
            return JSONResponse({"detail": f"Post {post_id} not found"}, status_code=404)

        if post.queue_status != QueueStatus.RESERVED:
            return JSONResponse(
                {"detail": f"Post is in state '{post.queue_status}', expected 'reserved'"},
                status_code=409,
            )

        post.queue_status = QueueStatus.PENDING
        post.reserved_at = None
        db.commit()

    return Response(status_code=204)


async def error_post(request: Request) -> Response:
    """Mark a reserved post as permanently failed.

    Curated calls this when it encounters a permanent error (post deleted
    on remote, account suspended, instance unreachable after retries). The
    request body must include a *reason* string. The post is moved to the
    *error* terminal state and will never be re-queued automatically.
    """
    try:
        post_id = int(request.path_params["post_id"])
    except (KeyError, ValueError):
        return JSONResponse({"detail": "Invalid post ID"}, status_code=400)

    try:
        body = await request.json()
    except Exception:
        return JSONResponse({"detail": "Invalid JSON body"}, status_code=400)

    try:
        payload = CuratedErrorRequest.model_validate(body)
    except Exception as exc:
        return JSONResponse({"detail": str(exc)}, status_code=422)

    with get_db() as db:
        post = db.get(Post, post_id)
        if post is None:
            return JSONResponse({"detail": f"Post {post_id} not found"}, status_code=404)

        if post.queue_status != QueueStatus.RESERVED:
            return JSONResponse(
                {"detail": f"Post is in state '{post.queue_status}', expected 'reserved'"},
                status_code=409,
            )

        post.queue_status = QueueStatus.ERROR
        post.reserved_at = None
        post.error_reason = payload.reason
        post.errored_at = datetime.now(UTC)
        db.commit()

    return Response(status_code=204)


async def requeue_post(request: Request) -> Response:
    """Manually return an errored post to *pending*.

    Operator action — allows re-queuing a post that failed with a transient
    error that has since been resolved.
    """
    try:
        post_id = int(request.path_params["post_id"])
    except (KeyError, ValueError):
        return JSONResponse({"detail": "Invalid post ID"}, status_code=400)

    with get_db() as db:
        post = db.get(Post, post_id)
        if post is None:
            return JSONResponse({"detail": f"Post {post_id} not found"}, status_code=404)

        if post.queue_status != QueueStatus.ERROR:
            return JSONResponse(
                {"detail": f"Post is in state '{post.queue_status}', expected 'error'"},
                status_code=409,
            )

        post.queue_status = QueueStatus.PENDING
        post.error_reason = None
        post.errored_at = None
        db.commit()

    return Response(status_code=204)


async def trim_pending(request: Request) -> Response:
    """Trim excess pending posts to maintain buffer invariant.

    Query parameter 'lookback_days' (optional, default 3) sets the window
    for computing consumption rate.
    Returns JSON with the count of deleted posts.
    """
    try:
        lookback_days = int(request.query_params.get("lookback_days", "3"))
        if lookback_days < 1:
            return JSONResponse({"detail": "lookback_days must be >= 1"}, status_code=400)
    except ValueError:
        return JSONResponse({"detail": "lookback_days must be an integer"}, status_code=400)

    with get_db() as db:
        deleted = trim_pending_posts(db, lookback_days)

    return JSONResponse({"deleted": deleted})


async def cleanup_posts(request: Request) -> Response:
    """Delete delivered posts older than 7 days and record stats.

    Query parameter 'retention_days' (optional, default 7) controls the age threshold.
    Returns JSON with the count of deleted posts.
    """
    try:
        retention_days = int(request.query_params.get("retention_days", "7"))
        if retention_days < 0:
            return JSONResponse({"detail": "retention_days must be >= 0"}, status_code=400)
    except ValueError:
        return JSONResponse({"detail": "retention_days must be an integer"}, status_code=400)

    with get_db() as db:
        deleted = cleanup_delivered_posts(db, retention_days)

    return JSONResponse({"deleted": deleted})


curated_routes = [
    Route("/next", get_next, methods=["GET"]),
    Route("/{post_id:int}/ack", ack_post, methods=["POST"]),
    Route("/{post_id:int}/nack", nack_post, methods=["POST"]),
    Route("/{post_id:int}/error", error_post, methods=["POST"]),
    Route("/{post_id:int}/requeue", requeue_post, methods=["POST"]),
    Route("/cleanup", cleanup_posts, methods=["POST"]),
    Route("/trim-pending", trim_pending, methods=["POST"]),
]
