"""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 math import ceil
from typing import Any

from sqlalchemy import delete
from sqlalchemy import func
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 QueueStats
from fenliu.schemas import CuratedErrorRequest
from fenliu.schemas import CuratedNextResponse
from fenliu.services.export_eligibility import check_reblog_filters

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

# Queue status constants
STATUS_PENDING = "pending"
STATUS_RESERVED = "reserved"
STATUS_DELIVERED = "delivered"
STATUS_ERROR = "error"


# ---------------------------------------------------------------------------
# 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 == STATUS_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 = STATUS_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
# ---------------------------------------------------------------------------


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.
    """
    with get_db() as db:
        # 1. Return timed-out reservations to the queue.
        _sweep_timed_out(db)

        # 2. Find the oldest pending post that passes reblog filters.
        stmt = select(Post).where(Post.queue_status == STATUS_PENDING).order_by(Post.reviewed_at.asc().nullslast())
        candidates = list(db.execute(stmt).scalars().all())

        selected: Post | None = None
        for post in candidates:
            # Eagerly load the stream relationship before check (it may be
            # needed for display purposes after we commit).
            _stream = post.stream
            result = check_reblog_filters(post, db)
            if result.eligible:
                selected = post
                break

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

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

        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 != STATUS_RESERVED:
            return JSONResponse(
                {"detail": f"Post is in state '{post.queue_status}', expected 'reserved'"},
                status_code=409,
            )

        post.queue_status = STATUS_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 != STATUS_RESERVED:
            return JSONResponse(
                {"detail": f"Post is in state '{post.queue_status}', expected 'reserved'"},
                status_code=409,
            )

        post.queue_status = STATUS_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 != STATUS_RESERVED:
            return JSONResponse(
                {"detail": f"Post is in state '{post.queue_status}', expected 'reserved'"},
                status_code=409,
            )

        post.queue_status = STATUS_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 != STATUS_ERROR:
            return JSONResponse(
                {"detail": f"Post is in state '{post.queue_status}', expected 'error'"},
                status_code=409,
            )

        post.queue_status = STATUS_PENDING
        post.error_reason = None
        post.errored_at = None
        db.commit()

    return Response(status_code=204)


def _get_or_create_queue_stats(db: Session) -> QueueStats:
    """Get or create the singleton QueueStats row."""
    stats = db.get(QueueStats, 1)
    if stats is None:
        stats = QueueStats(id=1)
        db.add(stats)
        db.flush()
    return stats


def _cleanup_delivered_posts(db: Session, retention_days: int = 7) -> int:
    """Delete delivered posts older than retention_days.

    Records the count in QueueStats before deletion to preserve historical stats.
    Returns the number of posts deleted.
    """
    cutoff = datetime.now(UTC) - timedelta(days=retention_days)

    # Count posts to delete
    count_stmt = select(func.count(Post.id)).where(Post.queue_status == STATUS_DELIVERED, Post.delivered_at < cutoff)
    count = db.execute(count_stmt).scalar() or 0

    if count > 0:
        # Update stats before deleting
        stats = _get_or_create_queue_stats(db)
        stats.total_deleted_delivered += count

        # Delete posts
        delete_stmt = delete(Post).where(Post.queue_status == STATUS_DELIVERED, Post.delivered_at < cutoff)
        db.execute(delete_stmt)
        db.commit()

    return count


def _compute_post_weights(
    pending_posts: list[Any],
) -> tuple[list[int], list[float]]:
    """Compute combined deletion weights for pending posts.

    Weight dimensions:
    - Age: older posts → higher weight
    - Engagement: fewer likes → higher weight
    - Author frequency: more posts from author → higher weight

    Returns (ids, combined_weights).
    """
    now = datetime.now(UTC).replace(tzinfo=UTC)

    # Extract raw data with timezone normalization
    ids: list[int] = []
    fetched_times: list[datetime | None] = []
    like_counts: list[int] = []
    authors: list[str | None] = []

    for post_id, fetched_at, likes, author in pending_posts:
        ids.append(post_id)
        normalized_ft = fetched_at
        if normalized_ft and normalized_ft.tzinfo is None:
            normalized_ft = normalized_ft.replace(tzinfo=UTC)
        fetched_times.append(normalized_ft)
        like_counts.append(likes or 0)
        authors.append(author)

    # Compute age weights (normalized 0-1, older → higher)
    age_seconds = [(now - ft).total_seconds() if ft else 0 for ft in fetched_times]
    max_age = max(age_seconds) if age_seconds else 1
    age_weights = [s / max_age if max_age > 0 else 0.5 for s in age_seconds]

    # Compute engagement weights (1.0 - normalized likes, no likes → higher)
    max_likes = max(like_counts) if like_counts else 1
    engagement_weights = (
        [0.5 for _ in like_counts] if max_likes == 0 else [1.0 - (lc / max_likes) for lc in like_counts]
    )

    # Compute author frequency weights (prolific authors → higher)
    author_freq: dict[str | None, int] = {}
    for author in authors:
        author_freq[author] = author_freq.get(author, 0) + 1

    max_author_freq = max(author_freq.values()) if author_freq else 1
    author_freq_weights = [
        author_freq.get(author, 0) / max_author_freq if max_author_freq > 0 else 0.5 for author in authors
    ]

    # Combined weight (sum of three dimensions)
    combined_weights = [age_weights[i] + engagement_weights[i] + author_freq_weights[i] for i in range(len(ids))]

    return ids, combined_weights


def _trim_pending_posts(db: Session, lookback_days: int = 3) -> int:
    """Delete excess pending posts using weighted random selection.

    Maintains invariant: pending_count >= 2 * daily_consumption_rate.

    Deletion probability is higher for:
    - Older posts (by fetched_at)
    - Posts with fewer likes (lower engagement)
    - Posts from prolific authors (many pending posts from same author)

    Returns the number of posts deleted.
    """
    # 1. Compute daily consumption rate from delivered posts
    cutoff = datetime.now(UTC) - timedelta(days=lookback_days)
    delivered_count = (
        db.execute(
            select(func.count(Post.id)).where(Post.queue_status == STATUS_DELIVERED, Post.delivered_at >= cutoff)
        ).scalar()
        or 0
    )

    if delivered_count == 0:
        # No consumption history yet
        return 0

    daily_rate = delivered_count / max(lookback_days, 1)
    target_buffer = ceil(2 * daily_rate)

    # 2. Count current pending posts
    pending_count = db.execute(select(func.count(Post.id)).where(Post.queue_status == STATUS_PENDING)).scalar() or 0

    if pending_count <= target_buffer:
        # Pending count is within acceptable range
        return 0

    excess = pending_count - target_buffer

    # 3. Load all pending posts with necessary fields
    stmt = select(Post.id, Post.fetched_at, Post.likes, Post.author_username).where(Post.queue_status == STATUS_PENDING)
    pending_posts = list(db.execute(stmt).all())

    if not pending_posts:
        return 0

    # 4. Compute deletion weights for each post
    ids, combined_weights = _compute_post_weights(pending_posts)

    # 5. Select posts for deletion using weighted random sampling (with replacement)
    # Note: Using random for non-cryptographic sampling (no sec requirement here)
    selected_indices = random.choices(range(len(ids)), weights=combined_weights, k=excess)  # noqa: S311
    selected_ids = list({ids[i] for i in selected_indices})  # Deduplicate

    # 6. Delete selected posts and update stats
    if selected_ids:
        delete_stmt = delete(Post).where(Post.id.in_(selected_ids))
        db.execute(delete_stmt)
        stats = _get_or_create_queue_stats(db)
        stats.total_deleted_pending += len(selected_ids)
        db.commit()
        return len(selected_ids)

    return 0


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"]),
]
