//! HTMX per-post engagement endpoints: views, ratings, and messages.
//!
//! Mounted under `/api/htmx/post`:
//!
//! - `POST /{id}/view`         — record a view, return the views-badge
//!   fragment.
//! - `POST /{id}/rate`         — upsert a 1–5 rating, return the stars
//!   fragment.
//! - `GET  /{id}/message-form` — return the message modal-form fragment.
//! - `POST /{id}/message`      — persist a message, return a thanks fragment.
//!
//! The router is stateless; handlers read `Extension<AppState>` for the
//! engagement repository and rate limiter (mirrors
//! [`crate::startup::api`]'s `notify_broadcast`). Visitor identity is the
//! opaque first-party `vid` cookie, minted on first contact — no raw IP or
//! user-agent is stored. Owner email notification for messages is layered in a
//! later stage; persistence here is already complete and independent of
//! delivery.

#[cfg(feature = "post-engagement")]
mod imp {
    use axum::extract::{Form, Path, Query};
    use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
    use axum::response::{Html, IntoResponse, Response};
    use axum::routing::{get, post};
    use axum::{Extension, Router};
    use serde::Deserialize;
    use uuid::Uuid;

    use crate::app_state::AppState;
    use crate::database::engagement::{NewPostMessage, RatingSummary};
    use crate::security::rate_limit::RateLimitResult;

    /// First-party visitor-identity cookie name.
    const VID_COOKIE: &str = "vid";

    pub fn htmx_post_routes() -> Router {
        Router::new()
            .route("/{id}/view", post(view))
            .route("/{id}/rating", get(rating))
            .route("/{id}/rating-badge", get(rating_badge))
            .route("/{id}/rate", post(rate))
            .route("/{id}/message-form", get(message_form))
            .route("/{id}/message", post(message))
            .route("/close", get(modal_close))
    }

    /// `GET /close` — empties `#modal-slot`, closing the message modal. Kept in
    /// this router so the feature carries no dependency on the auth modal.
    async fn modal_close() -> Response {
        (StatusCode::OK, Html(String::new())).into_response()
    }

    #[derive(Debug, Deserialize)]
    struct ViewForm {
        #[serde(default)]
        lang: Option<String>,
    }

    #[derive(Debug, Deserialize)]
    struct RateForm {
        value: i32,
        #[serde(default)]
        lang: Option<String>,
    }

    #[derive(Debug, Deserialize)]
    struct RatingQuery {
        #[serde(default)]
        lang: Option<String>,
    }

    #[derive(Debug, Deserialize)]
    struct MessageFormQuery {
        #[serde(default)]
        title: Option<String>,
        #[serde(default)]
        lang: Option<String>,
    }

    #[derive(Debug, Deserialize)]
    struct MessageForm {
        name: String,
        #[serde(default)]
        email: Option<String>,
        body: String,
        #[serde(default)]
        post_title: Option<String>,
        #[serde(default)]
        lang: Option<String>,
        /// Publish-consent checkbox. Present (any value) ⇒ the visitor
        /// authorised showing the comment publicly. Unchecked boxes are
        /// simply absent.
        #[serde(default)]
        publish: Option<String>,
        /// Honeypot — must stay empty. A non-empty value marks a bot.
        #[serde(default)]
        website: Option<String>,
    }

    // ── Views ──────────────────────────────────────────────────────────────

    async fn view(
        Extension(state): Extension<AppState>,
        Path(id): Path<String>,
        headers: HeaderMap,
        Form(form): Form<ViewForm>,
    ) -> Response {
        let lang = normalize_lang(form.lang.as_deref());
        let (vid, fresh) = resolve_visitor(&headers);
        match state
            .engagement_repository
            .record_view(&id, &lang, &vid)
            .await
        {
            Ok(count) => with_vid(views_badge(count), fresh, &vid),
            Err(e) => {
                tracing::warn!(post = %id, "record_view failed: {e}");
                internal_error()
            }
        }
    }

    // ── Ratings ────────────────────────────────────────────────────────────

    /// `GET /{id}/rating` — read-only widget for page load: current aggregate
    /// plus the caller's own vote highlighted. Does not mint a cookie (the view
    /// beacon owns identity minting); an unknown visitor simply has no
    /// highlight.
    async fn rating(
        Extension(state): Extension<AppState>,
        Path(id): Path<String>,
        headers: HeaderMap,
        Query(q): Query<RatingQuery>,
    ) -> Response {
        let lang = normalize_lang(q.lang.as_deref());
        let summary = match state.engagement_repository.rating_summary(&id).await {
            Ok(s) => s,
            Err(e) => {
                tracing::warn!(post = %id, "rating_summary failed: {e}");
                return internal_error();
            }
        };
        let mine = match read_cookie(&headers, VID_COOKIE) {
            Some(vid) if !vid.is_empty() => state
                .engagement_repository
                .visitor_rating(&id, &vid)
                .await
                .unwrap_or(None),
            _ => None,
        };
        (
            StatusCode::OK,
            Html(stars_fragment(&id, &summary, mine, &lang)),
        )
            .into_response()
    }

    /// `GET /{id}/rating-badge` — compact badge for the post grid. Empty body
    /// when the post has no votes yet, so cards without ratings show nothing.
    async fn rating_badge(
        Extension(state): Extension<AppState>,
        Path(id): Path<String>,
        Query(q): Query<RatingQuery>,
    ) -> Response {
        let lang = normalize_lang(q.lang.as_deref());
        match state.engagement_repository.rating_summary(&id).await {
            Ok(summary) => {
                (StatusCode::OK, Html(rating_badge_fragment(&summary, &lang))).into_response()
            }
            Err(e) => {
                tracing::warn!(post = %id, "rating_badge failed: {e}");
                (StatusCode::OK, Html(String::new())).into_response()
            }
        }
    }

    async fn rate(
        Extension(state): Extension<AppState>,
        Path(id): Path<String>,
        headers: HeaderMap,
        Form(form): Form<RateForm>,
    ) -> Response {
        let lang = normalize_lang(form.lang.as_deref());
        let (vid, fresh) = resolve_visitor(&headers);
        if is_rate_limited(&state, &vid) {
            return too_many_requests();
        }
        if !(1..=5).contains(&form.value) {
            return (
                StatusCode::BAD_REQUEST,
                Html(error_fragment("Rating must be between 1 and 5.")),
            )
                .into_response();
        }
        match state
            .engagement_repository
            .record_rating(&id, &vid, form.value)
            .await
        {
            Ok(summary) => with_vid(
                stars_fragment(&id, &summary, Some(form.value), &lang),
                fresh,
                &vid,
            ),
            Err(e) => {
                tracing::warn!(post = %id, "record_rating failed: {e}");
                internal_error()
            }
        }
    }

    // ── Messages ───────────────────────────────────────────────────────────

    async fn message_form(Path(id): Path<String>, Query(q): Query<MessageFormQuery>) -> Response {
        let title = q.title.unwrap_or_default();
        let lang = normalize_lang(q.lang.as_deref());
        (
            StatusCode::OK,
            Html(message_form_fragment(&id, &title, &lang)),
        )
            .into_response()
    }

    async fn message(
        Extension(state): Extension<AppState>,
        Path(id): Path<String>,
        headers: HeaderMap,
        Form(form): Form<MessageForm>,
    ) -> Response {
        let (vid, fresh) = resolve_visitor(&headers);
        let lang = normalize_lang(form.lang.as_deref());

        // Honeypot: a filled `website` field is a bot. Return the same thanks
        // fragment without persisting so the bot gets no signal.
        let honeypot_filled = !form.website.as_deref().unwrap_or("").trim().is_empty();
        if honeypot_filled {
            return with_vid(thanks_fragment(&lang), fresh, &vid);
        }
        if is_rate_limited(&state, &vid) {
            return too_many_requests();
        }
        if form.name.trim().is_empty() || form.body.trim().is_empty() {
            return (
                StatusCode::BAD_REQUEST,
                Html(error_fragment("Name and message are required.")),
            )
                .into_response();
        }

        let email = form
            .email
            .as_deref()
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .map(str::to_string);
        // Publish consent: record proof (timestamp + client IP) only when given.
        let publish_consent = form.publish.is_some();
        let (consent_at, consent_ip) = if publish_consent {
            (Some(chrono::Utc::now()), client_ip(&headers))
        } else {
            (None, None)
        };
        let new_msg = NewPostMessage {
            post_id: id.clone(),
            post_title: form.post_title.unwrap_or_default(),
            lang: lang.clone(),
            name: form.name.trim().to_string(),
            email,
            body: form.body.trim().to_string(),
            visitor_id: Some(vid.clone()),
            publish_consent,
            consent_at,
            consent_ip,
        };
        match state.engagement_repository.insert_message(&new_msg).await {
            Ok(_) => {
                // Best-effort owner notification. Persistence above is the source
                // of truth; delivery is additive and never fails the request.
                #[cfg(feature = "email")]
                notify_owner(&state, &new_msg).await;
                with_vid(thanks_fragment(&lang), fresh, &vid)
            }
            Err(e) => {
                tracing::warn!(post = %id, "insert_message failed: {e}");
                internal_error()
            }
        }
    }

    /// Email the site owner about a new post message. The recipient comes from
    /// `POST_MESSAGE_RECIPIENT` (falling back to `EMAIL_FROM`); with email
    /// disabled the send short-circuits to a no-op, so this is safe in dev.
    #[cfg(feature = "email")]
    async fn notify_owner(state: &AppState, msg: &NewPostMessage) {
        let recipient = std::env::var("POST_MESSAGE_RECIPIENT")
            .or_else(|_| std::env::var("EMAIL_FROM"))
            .ok()
            .filter(|s| !s.trim().is_empty());
        let Some(to) = recipient else {
            tracing::info!(
                post = %msg.post_id,
                "post message stored; set POST_MESSAGE_RECIPIENT to email the owner"
            );
            return;
        };
        let site = std::env::var("SITE_NAME").unwrap_or_else(|_| "site".to_string());
        let subject = format!("[{site}] {}", msg.post_title);
        let from = msg.email.as_deref().unwrap_or("(no email given)");
        let consent = if msg.publish_consent {
            format!(
                "Publish consent: YES (at {}, ip {})",
                msg.consent_at
                    .map(|t| t.to_rfc3339())
                    .unwrap_or_else(|| "unknown".to_string()),
                msg.consent_ip.as_deref().unwrap_or("unknown"),
            )
        } else {
            "Publish consent: no".to_string()
        };
        let body = format!(
            "New comment on post \"{title}\" ({post_id}, {lang}).\nFrom: {name} \
             <{from}>\n{consent}\n\n{message}",
            title = msg.post_title,
            post_id = msg.post_id,
            lang = msg.lang,
            name = msg.name,
            message = msg.body,
        );
        if let Err(e) = state
            .email_service
            .send_simple_email_with_language(&to, &subject, &body, &msg.lang)
            .await
        {
            tracing::warn!(post = %msg.post_id, "owner notification email failed: {e}");
        }
    }

    // ── Visitor identity ───────────────────────────────────────────────────

    /// Resolve the visitor id from the `vid` cookie, or mint a fresh one. The
    /// boolean is `true` when a new id was minted and must be set on the
    /// response.
    fn resolve_visitor(headers: &HeaderMap) -> (String, bool) {
        if let Some(existing) = read_cookie(headers, VID_COOKIE) {
            if !existing.is_empty() {
                return (existing, false);
            }
        }
        (Uuid::new_v4().to_string(), true)
    }

    /// Best-effort client IP for consent proof: `X-Forwarded-For` first hop,
    /// then `X-Real-IP`. `ConnectInfo` is not wired, so a direct-connection
    /// peer is not captured; behind the production proxy the first XFF hop
    /// is the real client address.
    fn client_ip(headers: &HeaderMap) -> Option<String> {
        if let Some(xff) = headers
            .get("x-forwarded-for")
            .and_then(|v| v.to_str().ok())
            .and_then(|s| s.split(',').next())
            .map(str::trim)
            .filter(|s| !s.is_empty())
        {
            return Some(xff.to_string());
        }
        headers
            .get("x-real-ip")
            .and_then(|v| v.to_str().ok())
            .map(|s| s.trim().to_string())
            .filter(|s| !s.is_empty())
    }

    fn read_cookie(headers: &HeaderMap, name: &str) -> Option<String> {
        let raw = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
        for pair in raw.split(';') {
            if let Some((k, v)) = pair.trim().split_once('=') {
                if k.trim() == name {
                    return Some(v.trim().to_string());
                }
            }
        }
        None
    }

    fn set_vid_cookie(headers: &mut HeaderMap, vid: &str) {
        let cookie =
            format!("{VID_COOKIE}={vid}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly");
        if let Ok(value) = HeaderValue::from_str(&cookie) {
            headers.append(HeaderName::from_static("set-cookie"), value);
        }
    }

    /// Wrap an HTML fragment in a 200 response, appending the `vid` cookie when
    /// it was freshly minted.
    fn with_vid(html: String, fresh: bool, vid: &str) -> Response {
        let mut resp = (StatusCode::OK, Html(html)).into_response();
        if fresh {
            set_vid_cookie(resp.headers_mut(), vid);
        }
        resp
    }

    fn is_rate_limited(state: &AppState, identifier: &str) -> bool {
        matches!(
            state.rate_limiter.is_allowed(identifier),
            RateLimitResult::RateLimited { .. }
        )
    }

    // ── Fragments ──────────────────────────────────────────────────────────

    fn views_badge(count: i64) -> String {
        format!(r#"<span class="pe-views" data-count="{count}">{count}</span>"#)
    }

    /// The interactive rating widget: a label, five star buttons, and the
    /// aggregate (only once there is at least one vote). Root carries
    /// `id="post-rating"` so an `outerHTML` swap replaces it in place. `mine`
    /// highlights the caller's own vote when known; each button posts to
    /// `/rate`.
    fn stars_fragment(
        post_id: &str,
        summary: &RatingSummary,
        mine: Option<i32>,
        lang: &str,
    ) -> String {
        let post = esc(post_id);
        let lang_attr = esc(lang);
        let mut stars = String::new();
        for n in 1..=5 {
            // Highlight up to the caller's own vote when they have one; otherwise
            // fill up to the rounded average.
            let threshold = mine.unwrap_or_else(|| summary.average.round() as i32);
            let filled = threshold >= n;
            let is_mine = mine == Some(n);
            let cls = if is_mine {
                "pe-star pe-star-mine"
            } else if filled {
                "pe-star pe-star-on"
            } else {
                "pe-star"
            };
            let star_label = t(lang, "Rate", "Valorar");
            stars.push_str(&format!(
                r##"<button type="button" class="{cls}" aria-label="{star_label} {n}" hx-post="/api/htmx/post/{post}/rate" hx-vals='{{"value":{n},"lang":"{lang_attr}"}}' hx-target="#post-rating" hx-swap="outerHTML">★</button>"##
            ));
        }
        let meta = if summary.count > 0 {
            let votes = if summary.count == 1 {
                t(lang, "vote", "voto")
            } else {
                t(lang, "votes", "votos")
            };
            format!(
                r#"<span class="pe-rating-meta">{avg:.1} · {count} {votes}</span>"#,
                avg = summary.average,
                count = summary.count,
            )
        } else {
            let hint = t(lang, "be the first", "sé el primero");
            format!(r#"<span class="pe-rating-hint">{hint}</span>"#)
        };
        format!(
            r#"<div id="post-rating" class="pe-rating" data-average="{avg:.2}" data-count="{count}"><div class="pe-stars">{stars}</div>{meta}</div>"#,
            avg = summary.average,
            count = summary.count,
        )
    }

    /// Compact rating badge for a grid card. Empty when the post has no votes.
    fn rating_badge_fragment(summary: &RatingSummary, lang: &str) -> String {
        if summary.count == 0 {
            return String::new();
        }
        let votes = if summary.count == 1 {
            t(lang, "vote", "voto")
        } else {
            t(lang, "votes", "votos")
        };
        format!(
            r#"<span class="pe-card-rating" title="{count} {votes}">★ {avg:.1} · {count}</span>"#,
            avg = summary.average,
            count = summary.count,
        )
    }

    /// Full modal for #modal-slot: backdrop + centered card + the message form.
    /// The backdrop and the ✕ button close the modal via `GET /close`.
    fn message_form_fragment(post_id: &str, title: &str, lang: &str) -> String {
        let post = esc(post_id);
        let title_attr = esc(title);
        let lang_attr = esc(lang);
        let heading = esc(t(lang, "Share your thoughts", "Comparte tu opinión"));
        let invite = esc(t(
            lang,
            "Tell me what you think, what you'd suggest, or whether we should keep exploring this \
             topic.",
            "Cuéntame qué opinas, qué sugieres, o si seguimos explorando este tema.",
        ));
        let l_name = t(lang, "Name", "Nombre");
        let l_email = t(lang, "Email (optional)", "Email (opcional)");
        let l_msg = t(lang, "Your comment", "Tu comentario");
        let l_publish = esc(t(
            lang,
            "I allow publishing my comment alongside this content.",
            "Autorizo a publicar mi comentario junto a este contenido.",
        ));
        let l_send = t(lang, "Send comment", "Enviar comentario");
        let l_close = t(lang, "Close", "Cerrar");
        format!(
            r##"<div class="pe-modal-backdrop" hx-get="/api/htmx/post/close" hx-target="#modal-slot" hx-swap="innerHTML" aria-hidden="true"></div><div class="pe-modal-wrap"><div role="dialog" aria-modal="true" class="pe-modal-card"><button type="button" class="pe-modal-close" hx-get="/api/htmx/post/close" hx-target="#modal-slot" hx-swap="innerHTML" aria-label="{l_close}" title="{l_close}">✕</button><div id="pe-msg-panel" class="pe-msg-panel"><h3 class="pe-modal-title">{heading}</h3><p class="pe-modal-sub" title="{title_attr}">{title_attr}</p><p class="pe-modal-invite">{invite}</p><form hx-post="/api/htmx/post/{post}/message" hx-target="#pe-msg-panel" hx-swap="outerHTML" class="pe-msg-form"><input type="hidden" name="post_title" value="{title_attr}"><input type="hidden" name="lang" value="{lang_attr}"><input type="text" name="website" tabindex="-1" autocomplete="off" class="pe-hp" aria-hidden="true"><label class="pe-field">{l_name}<input type="text" name="name" required class="ds-input"></label><label class="pe-field">{l_email}<input type="email" name="email" class="ds-input"></label><label class="pe-field">{l_msg}<textarea name="body" rows="4" required class="ds-input"></textarea></label><label class="pe-consent"><input type="checkbox" name="publish" value="1"><span>{l_publish}</span></label><button type="submit" class="pe-cta-btn pe-cta-btn-block">{l_send}</button></form></div></div></div>"##
        )
    }

    fn thanks_fragment(lang: &str) -> String {
        let msg = esc(t(
            lang,
            "Thanks — your message was sent.",
            "Gracias — tu mensaje se ha enviado.",
        ));
        let l_close = t(lang, "Close", "Cerrar");
        format!(
            r##"<div id="pe-msg-panel" class="pe-msg-panel pe-msg-thanks"><p class="ds-text mb-4">{msg}</p><button type="button" class="btn btn-primary" hx-get="/api/htmx/post/close" hx-target="#modal-slot" hx-swap="innerHTML">{l_close}</button></div>"##
        )
    }

    fn error_fragment(message: &str) -> String {
        format!(
            r#"<div class="pe-error" role="alert">{}</div>"#,
            esc(message)
        )
    }

    fn internal_error() -> Response {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            Html(error_fragment("Something went wrong. Please try again.")),
        )
            .into_response()
    }

    fn too_many_requests() -> Response {
        (
            StatusCode::TOO_MANY_REQUESTS,
            Html(error_fragment("Too many requests — please slow down.")),
        )
            .into_response()
    }

    // ── Helpers ────────────────────────────────────────────────────────────

    /// Restrict a language code to a short, safe token; default `en`.
    fn normalize_lang(lang: Option<&str>) -> String {
        match lang {
            Some(l) if l.len() <= 8 && l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') => {
                l.to_string()
            }
            _ => "en".to_string(),
        }
    }

    /// Pick the English or Spanish string for `lang`. Bilingual fragments avoid
    /// a `.ftl` round-trip for the handful of strings these endpoints emit.
    fn t<'a>(lang: &str, en: &'a str, es: &'a str) -> &'a str {
        if lang == "es" {
            es
        } else {
            en
        }
    }

    /// Minimal HTML-attribute/text escaping. Kept local so the
    /// `post-engagement` feature carries no extra dependency.
    fn esc(input: &str) -> String {
        let mut out = String::with_capacity(input.len());
        for ch in input.chars() {
            match ch {
                '&' => out.push_str("&amp;"),
                '<' => out.push_str("&lt;"),
                '>' => out.push_str("&gt;"),
                '"' => out.push_str("&quot;"),
                '\'' => out.push_str("&#39;"),
                _ => out.push(ch),
            }
        }
        out
    }

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

        fn headers_with(cookie: &str) -> HeaderMap {
            let mut h = HeaderMap::new();
            h.insert(
                axum::http::header::COOKIE,
                HeaderValue::from_str(cookie).expect("valid cookie header"),
            );
            h
        }

        #[test]
        fn resolve_visitor_reuses_existing_cookie() {
            let h = headers_with("vid=abc123; theme=dark");
            let (id, fresh) = resolve_visitor(&h);
            assert_eq!(id, "abc123");
            assert!(!fresh);
        }

        #[test]
        fn resolve_visitor_mints_when_absent() {
            let (id, fresh) = resolve_visitor(&HeaderMap::new());
            assert!(fresh);
            assert!(Uuid::parse_str(&id).is_ok());
        }

        #[test]
        fn stars_fragment_marks_own_vote_and_targets_rating() {
            let summary = RatingSummary {
                average: 4.0,
                count: 3,
            };
            let html = stars_fragment("my-post", &summary, Some(4), "en");
            assert!(html.contains(r#"id="post-rating""#));
            assert!(html.contains("pe-star-mine"));
            assert!(html.contains(r#"hx-post="/api/htmx/post/my-post/rate""#));
            assert!(html.contains("3 votes")); // aggregate shown when count > 0
            assert_eq!(html.matches("<button").count(), 5);
        }

        #[test]
        fn stars_fragment_hides_meta_when_no_votes() {
            let summary = RatingSummary {
                average: 0.0,
                count: 0,
            };
            let html = stars_fragment("p", &summary, None, "en");
            assert!(!html.contains("pe-rating-meta")); // no aggregate with zero votes
            assert!(html.contains("pe-rating-hint")); // shows the "be the first" nudge
            assert_eq!(html.matches("<button").count(), 5);
        }

        #[test]
        fn rating_badge_empty_without_votes() {
            assert_eq!(
                rating_badge_fragment(
                    &RatingSummary {
                        average: 0.0,
                        count: 0
                    },
                    "en"
                ),
                ""
            );
            let badge = rating_badge_fragment(
                &RatingSummary {
                    average: 3.5,
                    count: 2,
                },
                "es",
            );
            assert!(badge.contains("3.5"));
            assert!(badge.contains("votos"));
        }

        #[test]
        fn message_form_escapes_title() {
            let html = message_form_fragment("p1", r#"A "quoted" <title>"#, "en");
            assert!(html.contains("&quot;quoted&quot;"));
            assert!(html.contains("&lt;title&gt;"));
            assert!(html.contains(r#"name="website""#)); // honeypot present
        }

        #[test]
        fn normalize_lang_rejects_junk() {
            assert_eq!(normalize_lang(Some("en")), "en");
            assert_eq!(normalize_lang(Some("es-ES")), "es-ES");
            assert_eq!(normalize_lang(Some("../etc")), "en");
            assert_eq!(normalize_lang(None), "en");
        }
    }
}

#[cfg(feature = "post-engagement")]
pub use imp::htmx_post_routes;
