//! Per-page tag endpoints. See `migration 0023` for the underlying
//! schema and `repo::page_tag` for the query layer. All endpoints
//! require `CurrentUser` — every byte of this data is owned by the
//! caller.

use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;

use crate::api::pagination::PagedResponse;
use crate::app::AppState;
use crate::auth::extractor::CurrentUser;
use crate::domain::page_analysis::{ContentWarning, PageSearchItem};
use crate::domain::page_tag::{
    NewPageTag, PageTagSummary, TaggedChapterAggregate, TaggedMangaAggregate,
    TaggedPageItem,
};
use crate::error::{AppError, AppResult};
use crate::repo;
use crate::repo::page_analysis::PageSearchQuery;
use crate::repo::page_tag::Order;

pub fn routes() -> Router<AppState> {
    Router::new()
        .route("/pages/:id/tags", post(add))
        .route("/pages/:id/tags/:tag", delete(remove))
        // GET uses `/my-tags` to mirror the `mangas/:id/my-collections`
        // convention — the URL says whose tags we're reading even
        // though the cookie already implies it.
        .route("/pages/:id/my-tags", get(list_for_page))
        .route("/me/page-tags", get(list_mine))
        .route("/me/page-search", get(page_search))
        .route("/me/page-tags/distinct", get(list_distinct_mine))
        .route("/me/page-tags/chapters", get(list_chapters_for_tag))
        .route("/me/page-tags/mangas", get(list_mangas_for_tag))
}

const MAX_TAG_LEN: usize = 64;
/// Hard wire-level byte cap, applied before any allocation in
/// `normalize_tag`. A 10MB tag in the JSON body would otherwise
/// allocate twice in `to_lowercase()` + `split_whitespace().collect()`
/// before the 64-char post-normalize cap rejected it. 4 KiB is a
/// generous ~64x the post-normalize char cap and well below anything
/// a user could fairly call a "tag".
const MAX_TAG_BYTES: usize = 4096;
const DEFAULT_LIMIT: i64 = 50;
const DEFAULT_DISTINCT_LIMIT: i64 = 200;

#[derive(Debug, Deserialize)]
pub struct ListMineParams {
    #[serde(default = "default_limit")]
    pub limit: i64,
    #[serde(default)]
    pub offset: i64,
    /// Restrict to this exact tag (chip filter in the library tab).
    #[serde(default)]
    pub tag: Option<String>,
    /// Prefix filter (autocomplete-style).
    #[serde(default)]
    pub q: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct DistinctParams {
    #[serde(default)]
    pub q: Option<String>,
    #[serde(default = "default_distinct_limit")]
    pub limit: i64,
}

#[derive(Debug, Serialize)]
struct TagsResponse {
    tags: Vec<String>,
}

fn default_limit() -> i64 {
    DEFAULT_LIMIT
}
fn default_distinct_limit() -> i64 {
    DEFAULT_DISTINCT_LIMIT
}

/// Normalize a user-supplied tag for storage. Lowercases, trims,
/// collapses internal whitespace, rejects control chars and edge
/// colons, caps at 64 chars. Single source of truth so the same input
/// produces the same row regardless of which client sent it.
/// True for Unicode format / invisible chars that would let two
/// visually-identical tags coexist as distinct rows — `"funny"` and
/// `"funny\u{200d}"` are different codepoints but render the same, and
/// the autocomplete + chip cloud would split them. We hardcode the
/// well-known offenders (a subset of the Unicode `Cf` general category
/// plus the bidi-override block) so the file stays free of new deps.
fn is_invisible_format(c: char) -> bool {
    matches!(c,
        // Soft hyphen, Arabic letter mark, Mongolian vowel separator.
        '\u{00AD}' | '\u{061C}' | '\u{180E}'
        // ZWSP, ZWNJ, ZWJ, LRM, RLM.
        | '\u{200B}'..='\u{200F}'
        // LRE / RLE / PDF / LRO / RLO.
        | '\u{202A}'..='\u{202E}'
        // Word joiner, function-application markers, deprecated
        // formatting (U+206A..U+206F).
        | '\u{2060}'..='\u{2064}'
        | '\u{2066}'..='\u{206F}'
        // BOM / ZWNBSP.
        | '\u{FEFF}'
        // Plane-14 LANGUAGE TAG + TAG characters. These can tunnel
        // ASCII semantics invisibly (the same mechanism used in 2024's
        // LLM prompt-injection smuggling work). Storing them would
        // let two visually-identical tags coexist while carrying
        // distinct hidden payloads.
        | '\u{E0001}'
        | '\u{E0020}'..='\u{E007F}'
    )
}

pub(crate) fn normalize_tag(input: &str) -> AppResult<String> {
    // Fast-fail on absurd input before allocating in lowercase /
    // whitespace passes. Cheap and bounds worst-case work.
    if input.len() > MAX_TAG_BYTES {
        return Err(AppError::ValidationFailed {
            message: "tag too long".into(),
            details: json!({ "tag": format!("max {MAX_TAG_BYTES} bytes") }),
        });
    }
    let trimmed = input.trim();
    if trimmed.is_empty() {
        return Err(AppError::ValidationFailed {
            message: "tag is required".into(),
            details: json!({ "tag": "required" }),
        });
    }
    if trimmed.chars().any(|c| c.is_control()) {
        return Err(AppError::ValidationFailed {
            message: "tag contains control characters".into(),
            details: json!({ "tag": "control_chars" }),
        });
    }
    if trimmed.chars().any(is_invisible_format) {
        return Err(AppError::ValidationFailed {
            message: "tag contains invisible / format characters".into(),
            details: json!({ "tag": "invisible_chars" }),
        });
    }
    // Reject characters that would break the DELETE URL path
    // (`/v1/pages/:id/tags/:tag`). axum decodes %2F back to `/` after
    // routing, so a tag containing one of these would be silently
    // unreachable to the delete handler — store-only. Reject up front
    // so every stored tag is removable. `%` and `_` are also rejected
    // since they are LIKE wildcards in the `list_for_user` prefix
    // filter and would otherwise let a user accidentally search for
    // anything.
    if trimmed.chars().any(|c| matches!(c, '/' | '\\' | '?' | '#' | '%' | '_')) {
        return Err(AppError::ValidationFailed {
            message: "tag contains a forbidden character (/ \\ ? # % _)".into(),
            details: json!({ "tag": "forbidden_chars" }),
        });
    }
    // Lowercase + collapse internal whitespace runs (any kind: spaces,
    // tabs, fullwidth space, etc.) into a single ASCII space. This
    // keeps "Foo  Bar" and "foo bar" identifying the same tag.
    let normalized: String = trimmed
        .to_lowercase()
        .split_whitespace()
        .collect::<Vec<_>>()
        .join(" ");
    // `namespace:value` is permitted but a bare leading/trailing colon
    // is nonsense and would break any future split.
    if normalized.starts_with(':') || normalized.ends_with(':') {
        return Err(AppError::ValidationFailed {
            message: "tag must not start or end with ':'".into(),
            details: json!({ "tag": "edge_colon" }),
        });
    }
    if normalized.chars().count() > MAX_TAG_LEN {
        // "After normalization" because Unicode case-folding can
        // expand chars (Turkish capital `İ` → `i\u{307}`, so 33 İ's
        // pass the wire-level 33-char input but trip this 64 cap).
        return Err(AppError::ValidationFailed {
            message: "tag too long after normalization".into(),
            details: json!({ "tag": format!("max {MAX_TAG_LEN} characters") }),
        });
    }
    Ok(normalized)
}

async fn add(
    State(state): State<AppState>,
    CurrentUser(user): CurrentUser,
    Path(page_id): Path<Uuid>,
    Json(input): Json<NewPageTag>,
) -> AppResult<StatusCode> {
    let tag = normalize_tag(&input.tag)?;
    let created = repo::page_tag::upsert(&state.db, user.id, page_id, &tag).await?;
    Ok(if created { StatusCode::CREATED } else { StatusCode::OK })
}

async fn remove(
    State(state): State<AppState>,
    CurrentUser(user): CurrentUser,
    Path((page_id, tag)): Path<(Uuid, String)>,
) -> AppResult<StatusCode> {
    // Normalize the URL-decoded tag the same way add() did, so
    // DELETE /pages/.../tags/Funny removes the row stored as "funny".
    let normalized = normalize_tag(&tag)?;
    repo::page_tag::remove(&state.db, user.id, page_id, &normalized).await?;
    Ok(StatusCode::NO_CONTENT)
}

async fn list_for_page(
    State(state): State<AppState>,
    CurrentUser(user): CurrentUser,
    Path(page_id): Path<Uuid>,
) -> AppResult<Json<TagsResponse>> {
    let tags = repo::page_tag::list_for_page(&state.db, user.id, page_id).await?;
    Ok(Json(TagsResponse { tags }))
}

async fn list_mine(
    State(state): State<AppState>,
    CurrentUser(user): CurrentUser,
    Query(params): Query<ListMineParams>,
) -> AppResult<Json<PagedResponse<TaggedPageItem>>> {
    let limit = params.limit.clamp(1, 200);
    let offset = params.offset.max(0);
    // Filters from the wire arrive raw — normalize them so a filter on
    // "Funny" matches rows stored as "funny".
    let tag_filter = params
        .tag
        .as_deref()
        .map(normalize_tag)
        .transpose()?;
    let prefix_filter = params
        .q
        .as_deref()
        .map(normalize_tag)
        .transpose()?;
    let (items, total) = repo::page_tag::list_for_user(
        &state.db,
        user.id,
        tag_filter.as_deref(),
        prefix_filter.as_deref(),
        limit,
        offset,
    )
    .await?;
    Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
}

#[derive(Debug, Deserialize)]
pub struct PageSearchParams {
    #[serde(default = "default_limit")]
    pub limit: i64,
    #[serde(default)]
    pub offset: i64,
    /// Comma-separated tags, AND-ed. Matched against the caller's page
    /// tags ∪ the global auto-tags.
    #[serde(default)]
    pub tags: Option<String>,
    /// Free-text query over the OCR + scene-description search document.
    #[serde(default)]
    pub text: Option<String>,
    /// Comma-separated content warnings the page must carry (all of them).
    #[serde(default)]
    pub cw_include: Option<String>,
    /// Comma-separated content warnings the page must NOT carry (any of).
    #[serde(default)]
    pub cw_exclude: Option<String>,
}

/// Split a comma-separated tag list into normalized, deduped tag names.
fn parse_tags_csv(raw: Option<&str>) -> AppResult<Vec<String>> {
    let Some(raw) = raw else { return Ok(Vec::new()) };
    let mut seen = std::collections::HashSet::new();
    let mut out = Vec::new();
    for part in raw.split(',') {
        if part.trim().is_empty() {
            continue;
        }
        let norm = normalize_tag(part)?;
        if seen.insert(norm.clone()) {
            out.push(norm);
        }
    }
    Ok(out)
}

/// Split + validate a comma-separated content-warning list against the
/// closed vocabulary, returning canonical lowercase names. Unknown values
/// are a 422 rather than a silent drop so a typo'd filter is visible.
pub(crate) fn parse_warnings_csv(raw: Option<&str>) -> AppResult<Vec<String>> {
    let Some(raw) = raw else { return Ok(Vec::new()) };
    let mut seen = std::collections::HashSet::new();
    let mut out = Vec::new();
    for part in raw.split(',') {
        let t = part.trim();
        if t.is_empty() {
            continue;
        }
        let w = ContentWarning::parse_strict(t).ok_or_else(|| AppError::ValidationFailed {
            message: format!("unknown content warning {t:?}"),
            details: json!({ "content_warning": "must be one of sexual|nudity|gore|violence|disturbing" }),
        })?;
        let canon = format!("{w:?}").to_lowercase();
        if seen.insert(canon.clone()) {
            out.push(canon);
        }
    }
    Ok(out)
}

/// Content search over the caller's reachable pages: multi-tag AND (own
/// page tags ∪ global auto-tags), weighted OCR/scene text ranking, and
/// content-warning include/exclude. At least one positive filter (tags,
/// text, or cw_include) is required so the endpoint never dumps every page.
async fn page_search(
    State(state): State<AppState>,
    CurrentUser(user): CurrentUser,
    Query(params): Query<PageSearchParams>,
) -> AppResult<Json<PagedResponse<PageSearchItem>>> {
    let limit = params.limit.clamp(1, 200);
    let offset = params.offset.max(0);
    let tags = parse_tags_csv(params.tags.as_deref())?;
    let cw_include = parse_warnings_csv(params.cw_include.as_deref())?;
    let cw_exclude = parse_warnings_csv(params.cw_exclude.as_deref())?;
    let text = params
        .text
        .as_deref()
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(str::to_string);

    if tags.is_empty() && text.is_none() && cw_include.is_empty() {
        return Err(AppError::ValidationFailed {
            message: "at least one of tags, text, or cw_include is required".into(),
            details: json!({ "filter": "required" }),
        });
    }

    let query = PageSearchQuery {
        user_id: user.id,
        tags,
        text,
        cw_include,
        cw_exclude,
        limit,
        offset,
    };
    let (items, total) = repo::page_analysis::page_search(&state.db, &query).await?;
    Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
}

async fn list_distinct_mine(
    State(state): State<AppState>,
    CurrentUser(user): CurrentUser,
    Query(params): Query<DistinctParams>,
) -> AppResult<Json<DistinctResponse>> {
    let limit = params.limit.clamp(1, 500);
    let prefix = params
        .q
        .as_deref()
        .map(normalize_tag)
        .transpose()?;
    let items = repo::page_tag::distinct_tags_for_user(
        &state.db,
        user.id,
        prefix.as_deref(),
        limit,
    )
    .await?;
    Ok(Json(DistinctResponse { items }))
}

#[derive(Debug, Serialize)]
struct DistinctResponse {
    items: Vec<PageTagSummary>,
}

#[derive(Debug, Deserialize)]
pub struct AggregateParams {
    /// Required. Exact tag to aggregate by. Empty / whitespace-only
    /// inputs are rejected with 422 via `normalize_tag`.
    pub tag: String,
    /// `desc` (default) or `asc`. Anything else → 422.
    #[serde(default)]
    pub order: Option<String>,
    #[serde(default = "default_limit")]
    pub limit: i64,
    #[serde(default)]
    pub offset: i64,
    /// OCR text filter. When non-empty, only pages whose analysis
    /// `search_doc` matches the query (`plainto_tsquery`) are aggregated.
    /// Blank/absent ⇒ tag-only aggregation.
    #[serde(default)]
    pub text: Option<String>,
}

fn parse_order(raw: Option<&str>) -> AppResult<Order> {
    match raw.map(str::trim) {
        None | Some("") | Some("desc") => Ok(Order::Desc),
        Some("asc") => Ok(Order::Asc),
        Some(other) => Err(AppError::ValidationFailed {
            message: format!("order must be 'desc' or 'asc' (got {other:?})"),
            details: json!({ "order": "invalid" }),
        }),
    }
}

async fn list_chapters_for_tag(
    State(state): State<AppState>,
    CurrentUser(user): CurrentUser,
    Query(params): Query<AggregateParams>,
) -> AppResult<Json<PagedResponse<TaggedChapterAggregate>>> {
    let tag = normalize_tag(&params.tag)?;
    let order = parse_order(params.order.as_deref())?;
    let limit = params.limit.clamp(1, 200);
    let offset = params.offset.max(0);
    let (items, total) = repo::page_tag::aggregate_chapters_for_tag(
        &state.db, user.id, &tag, order, limit, offset, params.text.as_deref(),
    )
    .await?;
    Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
}

async fn list_mangas_for_tag(
    State(state): State<AppState>,
    CurrentUser(user): CurrentUser,
    Query(params): Query<AggregateParams>,
) -> AppResult<Json<PagedResponse<TaggedMangaAggregate>>> {
    let tag = normalize_tag(&params.tag)?;
    let order = parse_order(params.order.as_deref())?;
    let limit = params.limit.clamp(1, 200);
    let offset = params.offset.max(0);
    let (items, total) = repo::page_tag::aggregate_mangas_for_tag(
        &state.db, user.id, &tag, order, limit, offset, params.text.as_deref(),
    )
    .await?;
    Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
}

#[cfg(test)]
mod tests {
    use super::normalize_tag;

    #[test]
    fn lowercases_and_collapses_whitespace() {
        assert_eq!(normalize_tag("Funny").unwrap(), "funny");
        assert_eq!(normalize_tag("  Foo   Bar  ").unwrap(), "foo bar");
        assert_eq!(normalize_tag("character:Askeladd").unwrap(), "character:askeladd");
    }

    #[test]
    fn rejects_blank_input() {
        assert!(normalize_tag("").is_err());
        assert!(normalize_tag("   ").is_err());
    }

    #[test]
    fn rejects_control_chars() {
        assert!(normalize_tag("bad\ntag").is_err());
        assert!(normalize_tag("a\tb").is_err());
        // NEL (U+0085, Cc) — rejected via `char::is_control()`. Locked
        // here so a future stdlib drift surfaces as a test fail rather
        // than a silent acceptance.
        assert!(normalize_tag("a\u{0085}b").is_err());
        // NULL byte.
        assert!(normalize_tag("a\u{0000}b").is_err());
    }

    #[test]
    fn rejects_edge_colon() {
        assert!(normalize_tag(":foo").is_err());
        assert!(normalize_tag("foo:").is_err());
    }

    #[test]
    fn rejects_invisible_format_chars() {
        // ZWJ — visually indistinguishable from no-zwj, would split
        // "funny" and "funny\u{200d}" into distinct rows.
        assert!(normalize_tag("funny\u{200d}").is_err());
        // RLO — bidi override could let a tag display backwards.
        assert!(normalize_tag("a\u{202e}b").is_err());
        // ZWNBSP / BOM at the boundary survives `trim()`.
        assert!(normalize_tag("a\u{feff}b").is_err());
        // ZWSP, RLM also rejected.
        assert!(normalize_tag("a\u{200b}b").is_err());
        assert!(normalize_tag("a\u{200f}b").is_err());
        // Plane-14 LANGUAGE TAG + TAG-character block. These tunnel
        // ASCII invisibly via the supplementary tag mechanism.
        assert!(normalize_tag("a\u{E0001}b").is_err());
        assert!(normalize_tag("a\u{E0020}b").is_err());
        assert!(normalize_tag("a\u{E007F}b").is_err());
    }

    #[test]
    fn rejects_path_breaking_and_like_wildcards() {
        // `/` would survive %-encoding round-trip but axum decodes
        // %2F back to `/` after path routing, so the DELETE path
        // would never match. Same for `?`, `#`, `\`.
        assert!(normalize_tag("a/b").is_err());
        assert!(normalize_tag("a\\b").is_err());
        assert!(normalize_tag("a?b").is_err());
        assert!(normalize_tag("a#b").is_err());
        // `%` and `_` are SQL LIKE wildcards in the prefix filter.
        assert!(normalize_tag("a%b").is_err());
        assert!(normalize_tag("a_b").is_err());
    }

    #[test]
    fn rejects_too_long() {
        let long = "a".repeat(65);
        assert!(normalize_tag(&long).is_err());
        let ok_len = "a".repeat(64);
        assert!(normalize_tag(&ok_len).is_ok());
    }

    #[test]
    fn rejects_pathologically_long_input_before_allocating() {
        // 10 MiB of `a` — should fail-fast on the byte cap, not
        // allocate twice through the lowercase/whitespace passes.
        let huge = "a".repeat(10 * 1024 * 1024);
        assert!(normalize_tag(&huge).is_err());
        // Just under cap still goes through the rest of the pipeline
        // and fails on the char count.
        let near_cap = "a".repeat(super::MAX_TAG_BYTES);
        assert!(normalize_tag(&near_cap).is_err());
    }
}
