//! Content grid renderer for `/api/htmx/content/{kind}` (F-08).
//!
//! Filters `load_content_index` results by tag and free-text query, paginates,
//! and emits a card grid + paginator wrapped in a morph-friendly container.
//! Registered with the framework via `register_content_grid_renderer`.

use html_escape::{encode_double_quoted_attribute, encode_text};
use rustelo_core_lib::fluent::models::ContentIndex;
use rustelo_server::api::{ContentGridError, ContentGridQuery};

const PAGE_SIZE: usize = 12;

/// Renderer signature compatible with [`rustelo_server::api::ContentGridRenderer`].
pub fn render_content_grid(query: &ContentGridQuery) -> Result<String, ContentGridError> {
    // Fall back to the default language when the requested language has no content.
    let default = rustelo_core_lib::config::get_default_language();
    let content_lang = match rustelo_core_lib::load_content_index(&query.kind, &query.language) {
        Ok(ref idx) if idx.items.iter().any(|i| i.published) => query.language.as_str(),
        _ => default,
    };
    let index = rustelo_core_lib::load_content_index(&query.kind, content_lang)
        .map_err(|e| ContentGridError::Other(e.to_string()))?;

    let style_mode = style_mode_for_kind(&query.kind);
    let filtered = apply_filters(&index, query);
    let total = filtered.len();
    let page_count = total.div_ceil(PAGE_SIZE).max(1);
    let page = (query.page as usize).min(page_count);
    let start = (page - 1) * PAGE_SIZE;
    let end = (start + PAGE_SIZE).min(total);
    let slice = &filtered[start..end];

    Ok(render_grid_body(slice, query, page, page_count, total, style_mode))
}

/// Render the complete grid host: tag-filter panel + search form + initial grid.
///
/// Called from the HTMX shell to build the full index page. Bypasses the
/// framework's `render_content_grid` wrapper so we can provide a custom filter
/// panel with tag counts and text search within the tag list.
pub fn render_grid_host(kind: &str, language: &str, active_tag: Option<&str>) -> String {
    use rustelo_server::api::ContentGridQuery;

    // Read query params from the injected x-request-query header (set by
    // fallback_handler for direct URL access like /blog?tag=rust&q=nush).
    // This lets the filter panel and search box pre-populate on initial render.
    let (query_tag, query_q) = rustelo_server::run::current_request_headers()
        .map(|h| {
            let qs = h
                .get("x-request-query")
                .and_then(|v| v.to_str().ok())
                .unwrap_or("");
            let mut tag = None::<String>;
            let mut q = None::<String>;
            for pair in qs.split('&') {
                if let Some((k, v)) = pair.split_once('=') {
                    let decoded = urlencoding_decode(v);
                    match k {
                        "tag" if !decoded.is_empty() => tag = Some(decoded),
                        "q" if !decoded.is_empty() => q = Some(decoded),
                        _ => {}
                    }
                }
            }
            (tag, q)
        })
        .unwrap_or((None, None));

    // Path-based tag takes priority over query-param tag.
    let resolved_tag: Option<String> = active_tag
        .map(|t| t.to_string())
        .or(query_tag);
    let resolved_q: Option<String> = query_q;

    let default = rustelo_core_lib::config::get_default_language();
    let content_lang = match rustelo_core_lib::load_content_index(kind, language) {
        Ok(ref idx) if idx.items.iter().any(|i| i.published) => language,
        _ => default,
    };

    let index = match rustelo_core_lib::load_content_index(kind, content_lang) {
        Ok(idx) => idx,
        Err(e) => {
            tracing::error!(kind, content_lang, error = %e, "grid host: index load failed");
            return r#"<p class="ds-text-error">Content unavailable.</p>"#.to_string();
        }
    };

    let initial_query = ContentGridQuery {
        kind: kind.to_string(),
        tag: resolved_tag.clone(),
        q: resolved_q.clone(),
        page: 1,
        language: content_lang.to_string(),
    };
    let initial_grid = render_content_grid(&initial_query).unwrap_or_else(|e| {
        format!(r#"<p class="ds-text-error">Grid load failed: {e}</p>"#)
    });

    let filter_panel = render_tag_filter_panel(&index, kind, language, resolved_tag.as_deref());
    let canonical = canonical_page_path(kind, language);
    let canonical_attr = encode_double_quoted_attribute(canonical);
    let endpoint_raw = format!("/api/htmx/content/{kind}");
    let endpoint = encode_double_quoted_attribute(&endpoint_raw);
    let grid_target_raw = format!("#content-grid-{kind}");
    let grid_target = encode_double_quoted_attribute(&grid_target_raw);
    let search_ph = if language == "es" { "Buscar…" } else { "Search…" };
    let search_label = if language == "es" { "Buscar en posts" } else { "Search posts" };
    let kind_attr = encode_double_quoted_attribute(kind);

    // Pre-populate search box value if set via direct URL.
    let q_value_attr = resolved_q
        .as_deref()
        .map(|q| format!(r#" value="{v}""#, v = encode_double_quoted_attribute(q)))
        .unwrap_or_default();

    // Script: corrects browser URL after every grid swap (tag or search).
    // Uses document.addEventListener (not htmx.on) because this inline script
    // runs at parse time, before the deferred htmx.min.js defines the `htmx`
    // global. htmx lifecycle events bubble up to document, so the native
    // listener catches them without referencing the global.
    let url_fix_script = format!(
        r#"<script>
(function(){{
  document.addEventListener('htmx:afterSettle', function(ev){{
    var t = ev.detail && ev.detail.target;
    if(!t || t.id !== 'content-grid-{kind}') return;
    var host = document.querySelector('[data-grid-host="{kind}"]');
    if(!host) return;
    var q = (host.querySelector('input[name=q]')||{{}}).value || '';
    var sel = ((host.querySelector('[data-tag-panel]')||{{}}).dataset||{{}}).selectedTags || '';
    var qs = [];
    if(sel) qs.push('tag='+encodeURIComponent(sel));
    if(q) qs.push('q='+encodeURIComponent(q));
    var url = '{canonical}' + (qs.length ? '?' + qs.join('&') : '');
    if(url !== location.pathname + location.search) history.pushState({{}}, '', url);
  }});
}})();
</script>"#,
    );

    // Layout: aside is on the right on desktop, on top on mobile.
    format!(
        r##"<section data-grid-host="{kind_attr}" data-loading-states data-canonical="{canonical_attr}">
<div class="flex flex-col lg:flex-row gap-6">
<div class="flex-1 min-w-0 order-2 lg:order-1">
{initial_grid}
</div>
<aside class="w-full lg:w-60 shrink-0 order-1 lg:order-2">
<form data-grid-search data-canonical="{canonical_attr}" hx-get="{endpoint}" hx-target="{grid_target}" hx-swap="morph" hx-trigger="keyup changed delay:400ms from:input[name=q], submit" hx-push-url="false" hx-include="this" hx-sync="this:replace" class="mb-3">
<input type="search" name="q" placeholder="{search_ph}" aria-label="{search_label}" autocomplete="off" data-loading-disable class="ds-input ds-input-sm w-full"{q_value_attr}>
</form>
{filter_panel}
</aside>
</div>
{url_fix_script}</section>"##,
    )
}

/// Minimal percent-decode for query param values (handles %2C, %20, + etc.).
fn urlencoding_decode(s: &str) -> String {
    let s = s.replace('+', " ");
    let mut out = String::with_capacity(s.len());
    let mut bytes = s.bytes();
    while let Some(b) = bytes.next() {
        if b == b'%' {
            let h = bytes.next().map(|c| c as char).unwrap_or('0');
            let l = bytes.next().map(|c| c as char).unwrap_or('0');
            if let Ok(n) = u8::from_str_radix(&format!("{h}{l}"), 16) {
                out.push(n as char);
            }
        } else {
            out.push(b as char);
        }
    }
    out
}

/// Render the collapsible tag-filter panel.
///
/// Shows all tags from the index grouped by category, with per-tag item counts
/// and a client-side text filter for the tag list. Returns OOB-safe HTML for
/// use in both initial renders and HTMX responses.
pub fn render_tag_filter_panel(
    index: &rustelo_core_lib::fluent::models::ContentIndex,
    kind: &str,
    language: &str,
    active_tag: Option<&str>,
) -> String {
    // Build tag → count map and tag → categories map from published items.
    let mut tag_counts: std::collections::BTreeMap<String, usize> = Default::default();
    let mut tag_categories: std::collections::HashMap<String, std::collections::BTreeSet<String>> =
        Default::default();

    for item in &index.items {
        if !item.published {
            continue;
        }
        for tag in &item.tags {
            *tag_counts.entry(tag.clone()).or_insert(0) += 1;
            for cat in &item.categories {
                tag_categories
                    .entry(tag.clone())
                    .or_default()
                    .insert(cat.clone());
            }
        }
    }

    if tag_counts.is_empty() {
        return String::new();
    }

    let canonical = canonical_page_path(kind, language);
    let endpoint = format!("/api/htmx/content/{kind}");
    let grid_id = format!("content-grid-{kind}");

    // Active tags as a Set (comma-separated in active_tag).
    let active_tags: std::collections::HashSet<&str> = active_tag
        .map(|s| s.split(',').filter(|t| !t.is_empty()).collect())
        .unwrap_or_default();

    let mut tags_sorted: Vec<(&str, usize)> =
        tag_counts.iter().map(|(t, c)| (t.as_str(), *c)).collect();
    tags_sorted.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));

    let kind_enc = encode_double_quoted_attribute(kind);
    let canonical_enc = encode_double_quoted_attribute(canonical);
    let endpoint_enc = encode_double_quoted_attribute(&endpoint);
    let grid_id_enc = encode_double_quoted_attribute(&grid_id);

    // Class tokens toggled by JS for active/inactive pills. Kept as named
    // constants so the Rust render and the JS toggle stay in sync.
    const PILL_BASE: &str = "grid-tag-pill inline-flex items-center gap-1 text-xs py-0.5 px-2 rounded-full border cursor-pointer transition-all select-none hover:border-base-content/50";
    const PILL_ON: &str = "opacity-100 border-current font-medium bg-base-content/10";
    const PILL_OFF: &str = "opacity-50 border-base-content/20 bg-transparent";

    let mut pills = String::new();
    for (tag, count) in &tags_sorted {
        let is_active = active_tags.contains(tag);
        let tag_enc = encode_double_quoted_attribute(tag);
        // Emoji from the meta config — same API as Leptos mode
        let emoji = rustelo_core_lib::get_tag_emoji(kind, language, tag);
        let emoji_prefix = if emoji.is_empty() {
            String::new()
        } else {
            format!(r#"<span aria-hidden="true">{emoji}</span>"#)
        };
        pills.push_str(&format!(
            r##"<button type="button" data-tag-pill data-tag="{tag_enc}" aria-pressed="{pressed}" onclick="gridToggleTag(this,'{tag_js}','{kind_enc}','{endpoint_enc}','#{grid_id_enc}')" class="{PILL_BASE} {state_cls}">{emoji_prefix}{label}<span class="opacity-50 text-[10px]">{count}</span></button>"##,
            tag_js    = tag.replace('\'', "\\'"),
            pressed   = if is_active { "true" } else { "false" },
            state_cls = if is_active { PILL_ON } else { PILL_OFF },
            label = encode_text(tag),
        ));
    }

    let search_ph = if language == "es" { "Filtrar etiquetas…" } else { "Filter tags…" };
    let filter_title = if language == "es" { "Etiquetas" } else { "Tags" };
    let clear_label = if language == "es" { "Limpiar" } else { "Clear" };

    // Reset button — always in the DOM; JS toggles `hidden` based on selection.
    // (The aside panel is not re-rendered on tag clicks — only the grid is — so
    // its state must be managed client-side.)
    let clear_hidden = if active_tags.is_empty() { " hidden" } else { "" };
    let clear_btn = format!(
        r##"<button type="button" data-clear-tags onclick="gridClearTags('{kind_enc}','{endpoint_enc}','#{grid_id_enc}')" class="text-xs ds-text cursor-pointer transition-opacity ml-auto flex items-center gap-1 opacity-60 hover:opacity-100{clear_hidden}">↺ {clear_label}</button>"##,
    );

    // Self-contained JS for multi-tag toggling. Pill state, clear-button
    // visibility and the canonical URL are all managed client-side because only
    // the grid (not this panel) is swapped on each tag click.
    let toggle_script = format!(
        r#"<script>
(function(){{
  function setPill(el, on) {{
    el.setAttribute('aria-pressed', on ? 'true' : 'false');
    '{on}'.split(' ').forEach(function(c){{ el.classList.toggle(c, on); }});
    '{off}'.split(' ').forEach(function(c){{ el.classList.toggle(c, !on); }});
  }}
  function syncClear(panel) {{
    var btn = panel.querySelector('[data-clear-tags]');
    if (btn) btn.classList.toggle('hidden', !(panel.dataset.selectedTags || ''));
  }}
  window.gridToggleTag = window.gridToggleTag || function(el, tag, kind, endpoint, target) {{
    var panel = el.closest('[data-tag-panel]');
    var sel = new Set((panel.dataset.selectedTags || '').split(',').filter(Boolean));
    if (sel.has(tag)) {{ sel.delete(tag); setPill(el, false); }} else {{ sel.add(tag); setPill(el, true); }}
    var tags = [...sel].join(',');
    panel.dataset.selectedTags = tags;
    syncClear(panel);
    htmx.ajax('GET', endpoint + (tags ? '?tag='+encodeURIComponent(tags) : ''), {{target: target, swap: 'morph'}});
  }};
  window.gridClearTags = window.gridClearTags || function(kind, endpoint, target) {{
    var panel = document.querySelector('[data-tag-panel][data-kind="' + kind + '"]');
    if (panel) {{
      panel.dataset.selectedTags = '';
      panel.querySelectorAll('[data-tag-pill]').forEach(function(b) {{ setPill(b, false); }});
      syncClear(panel);
    }}
    htmx.ajax('GET', endpoint, {{target: target, swap: 'morph'}});
  }};
  var inp = document.getElementById('tag-text-filter-{kind}');
  if (inp) {{
    inp.addEventListener('input', function() {{
      var q = this.value.toLowerCase();
      inp.closest('[data-tag-panel]').querySelectorAll('[data-tag-pill]').forEach(function(b) {{
        b.style.display = (!q || b.dataset.tag.toLowerCase().includes(q)) ? '' : 'none';
      }});
    }});
  }}
}})();
</script>"#,
        on = PILL_ON,
        off = PILL_OFF,
    );

    let initial_selected = active_tag.unwrap_or("");

    format!(
        r##"<div data-tag-panel data-kind="{kind_enc}" data-selected-tags="{initial_selected_enc}" class="tag-filter-panel space-y-2">
<div class="flex items-center gap-2">
<span class="text-xs font-semibold ds-text uppercase tracking-wide">{filter_title}</span>{clear_btn}
</div>
<input id="tag-text-filter-{kind}" type="text" placeholder="{search_ph}" class="ds-input ds-input-xs w-full" autocomplete="off">
<div class="flex flex-wrap gap-1.5">{pills}</div>
</div>{toggle_script}"##,
        initial_selected_enc = encode_double_quoted_attribute(initial_selected),
    )
}

/// Map (kind, language) → canonical page path for `hx-push-url`.
/// Mirrors the bilingual route aliases in site/config/routes.ncl so clicking a
/// tag pushes a clean URL like /blog?tag=rust instead of /api/htmx/content/blog.
fn canonical_page_path(kind: &str, lang: &str) -> &'static str {
    match (kind, lang) {
        ("blog", _) => "/blog",
        ("projects", "es") => "/proyectos",
        ("projects", _) => "/projects",
        ("recipes", "es") => "/recetas",
        ("recipes", _) => "/recipes",
        ("activities", "es") => "/actividades",
        ("activities", _) => "/activities",
        _ => "/blog",
    }
}

fn style_mode_for_kind(kind: &str) -> &'static str {
    // Mirrors site/config/content.ncl: blog uses row (vertical stack), all
    // others use grid. Kept here rather than read from the registry because
    // ContentKindRegistry at this call-site carries rustelo_core_types::ContentFeatures
    // (which has no style_mode field), not content::traits::ContentFeatures.
    match kind {
        "blog" => "row",
        _ => "grid",
    }
}

fn apply_filters<'a>(
    index: &'a ContentIndex,
    query: &ContentGridQuery,
) -> Vec<&'a rustelo_core_lib::content::UnifiedContentItem> {
    let needle = query
        .q
        .as_ref()
        .map(|s| s.to_lowercase())
        .filter(|s| !s.is_empty());
    // Support comma-separated multi-tag: "rust,devops" → OR filter across all tags.
    let active_tags: Vec<String> = query
        .tag
        .as_deref()
        .unwrap_or("")
        .split(',')
        .map(|s| s.trim().to_lowercase())
        .filter(|s| !s.is_empty())
        .collect();

    index
        .items
        .iter()
        .filter(|item| {
            if !item.published {
                return false;
            }
            // Match against tags OR categories — the URL category segment
            // (/blog/rust) and the tag pills both flow through `tag`, and the
            // two namespaces overlap (e.g. "rust" is both a category and a tag).
            if !active_tags.is_empty() {
                let matches = item
                    .tags
                    .iter()
                    .chain(item.categories.iter())
                    .any(|x| active_tags.iter().any(|t| x.eq_ignore_ascii_case(t)));
                if !matches {
                    return false;
                }
            }
            if let Some(q) = &needle {
                let in_title = item.title.to_lowercase().contains(q);
                let in_excerpt = item
                    .excerpt
                    .as_ref()
                    .map(|e| e.to_lowercase().contains(q))
                    .unwrap_or(false);
                let in_subtitle = item
                    .subtitle
                    .as_ref()
                    .map(|s| s.to_lowercase().contains(q))
                    .unwrap_or(false);
                if !(in_title || in_excerpt || in_subtitle) {
                    return false;
                }
            }
            true
        })
        .collect()
}

fn render_grid_body(
    items: &[&rustelo_core_lib::content::UnifiedContentItem],
    query: &ContentGridQuery,
    page: usize,
    page_count: usize,
    total: usize,
    style_mode: &'static str,
) -> String {
    let cards = if items.is_empty() {
        r#"<p class="ds-text-muted">No results.</p>"#.to_string()
    } else {
        let wrapper_class = match style_mode {
            "row" => "flex flex-col gap-6",
            _ => "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6",
        };
        let mut out = format!(r#"<div class="{wrapper_class}">"#);
        for item in items {
            out.push_str(&render_card(item));
        }
        out.push_str("</div>");
        out
    };
    let paginator = render_paginator(query, page, page_count, total);
    format!(
        r##"<section id="content-grid-{kind}" data-content-grid="true">{cards}{paginator}</section>"##,
        kind = encode_double_quoted_attribute(&query.kind),
    )
}

fn render_card(item: &rustelo_core_lib::content::UnifiedContentItem) -> String {
    let id = encode_double_quoted_attribute(&item.id);
    let title_text = encode_text(&item.title);
    // url_path() resolves the canonical URL including category segment when present
    // (e.g. /blog/devops/building-ci-cd). Matches what the HTMX dispatcher expects.
    let href = item.url_path();
    let href_attr = encode_double_quoted_attribute(&href);

    let figure_html = match &item.thumbnail {
        Some(thumb) => {
            let dark = item
                .thumbnail_dark
                .as_ref()
                .map(|d| {
                    format!(
                        r#"<img src="{src}" alt="{alt}" class="w-full grid-post-img grid-post-img-dark hover:scale-105 transition-transform duration-300">"#,
                        src = encode_double_quoted_attribute(d),
                        alt = encode_double_quoted_attribute(&item.title),
                    )
                })
                .unwrap_or_default();
            let light_class = if item.thumbnail_dark.is_some() {
                "w-full grid-post-img grid-post-img-light hover:scale-105 transition-transform duration-300"
            } else {
                "w-full grid-post-img hover:scale-105 transition-transform duration-300"
            };
            format!(
                r##"<figure class="overflow-hidden rounded-t-2xl grid-post-figure"><a href="{href_attr}"><img src="{src}" alt="{alt}" class="{light_class}">{dark}</a></figure>"##,
                src = encode_double_quoted_attribute(thumb),
                alt = encode_double_quoted_attribute(&item.title),
            )
        }
        None => String::new(),
    };

    let excerpt_html = match item.excerpt.as_deref() {
        Some(e) if !e.is_empty() => format!(
            r#"<p class="ds-card-text mb-4 line-clamp-3">{}</p>"#,
            encode_text(e)
        ),
        _ => String::new(),
    };

    let kind = &item.content_type;
    let lang = &item.language;
    let canonical = canonical_page_path(kind, lang);
    let endpoint = format!("/api/htmx/content/{kind}");
    let grid_id = format!("#content-grid-{kind}");

    let category_html = match item.categories.first() {
        Some(cat) => {
            let cat_enc = encode_double_quoted_attribute(cat);
            let ep = encode_double_quoted_attribute(&endpoint);
            let gid = encode_double_quoted_attribute(&grid_id);
            let push_raw = format!("{canonical}/{}", urlencode(cat));
            let push = encode_double_quoted_attribute(&push_raw);
            format!(
                r##"<button type="button" hx-get="{ep}?tag={cat_enc}" hx-target="{gid}" hx-swap="morph" hx-push-url="{push}" class="bg-transparent border-0 p-0 text-[11px] opacity-50 hover:opacity-80 cursor-pointer transition-opacity font-semibold uppercase tracking-widest text-left">{label}</button>"##,
                label = encode_text(cat),
            )
        }
        None => String::new(),
    };

    // Tags as plain text links — no borders, no background, minimal opacity.
    // Clean path URLs: /blog/rust so they're bookmark-friendly.
    let tags_html = if item.tags.is_empty() {
        String::new()
    } else {
        let mut pills = String::new();
        for tag in &item.tags {
            let tag_enc = encode_double_quoted_attribute(tag);
            let ep = encode_double_quoted_attribute(&endpoint);
            let gid = encode_double_quoted_attribute(&grid_id);
            let push_raw = format!("{canonical}/{}", urlencode(tag));
            let push = encode_double_quoted_attribute(&push_raw);
            pills.push_str(&format!(
                r##"<button type="button" hx-get="{ep}?tag={tag_enc}" hx-target="{gid}" hx-swap="morph" hx-push-url="{push}" class="bg-transparent border-0 p-0 text-xs opacity-35 hover:opacity-70 cursor-pointer transition-opacity">{label}</button>"##,
                label = encode_text(tag),
            ));
        }
        format!(r#"<div class="flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5">{pills}</div>"#)
    };

    // Date + read-time + Read more footer
    let date_html = if !item.created_at.is_empty() {
        // Trim to YYYY-MM-DD for display
        let date_short = item.created_at.get(..10).unwrap_or(&item.created_at);
        format!(r#"<time class="text-xs ds-text-muted" datetime="{dt}">{dt}</time>"#, dt = encode_text(date_short))
    } else {
        String::new()
    };
    let read_time_html = item.read_time.as_deref().filter(|r| !r.is_empty()).map(|r| {
        format!(r#"<span class="text-xs ds-text-muted">{}</span>"#, encode_text(r))
    }).unwrap_or_default();
    let read_more_label = if item.language == "es" { "Leer más" } else { "Read more" };
    let footer_html = format!(
        r##"<div class="flex items-center justify-between gap-3 mt-4 pt-3 border-t border-base-200/60"><div class="flex flex-col gap-0.5 text-xs ds-text-muted">{date_html}{read_time_html}</div><a href="{href_attr}" class="ds-btn ds-btn-primary ds-btn-sm px-4 py-1 whitespace-nowrap no-underline">{read_more}</a></div>"##,
        read_more = read_more_label,
    );

    format!(
        r##"<article id="card-{id}" class="ds-card ds-bg-base-100 ds-shadow-xl h-full flex flex-col">{figure_html}<div class="ds-card-body p-6 flex flex-col flex-1"><div class="flex-grow"><h3 class="ds-card-title mb-3"><a href="{href_attr}" class="ds-link-hover no-underline hover:text-primary transition-colors">{title_text}</a></h3>{excerpt_html}</div><div class="flex flex-col gap-2 mb-1">{category_html}{tags_html}</div>{footer_html}</div></article>"##,
    )
}

fn render_paginator(
    query: &ContentGridQuery,
    page: usize,
    page_count: usize,
    total: usize,
) -> String {
    if page_count <= 1 {
        return format!(
            r#"<p class="ds-paginator-summary">{total} item{plural}</p>"#,
            total = total,
            plural = if total == 1 { "" } else { "s" }
        );
    }

    let prev_disabled = page == 1;
    let next_disabled = page >= page_count;
    let prev_url = grid_url(query, page.saturating_sub(1).max(1));
    let next_url = grid_url(query, (page + 1).min(page_count));

    format!(
        r##"<nav class="ds-paginator" aria-label="Pagination"><button type="button" class="ds-btn-ghost-sm"{prev_attr} hx-get="{prev}" hx-target="closest [data-content-grid]" hx-swap="outerHTML" hx-push-url="true">Previous</button><span class="ds-paginator-page">Page {page} of {page_count}</span><button type="button" class="ds-btn-ghost-sm"{next_attr} hx-get="{next}" hx-target="closest [data-content-grid]" hx-swap="outerHTML" hx-push-url="true">Next</button></nav>"##,
        prev_attr = if prev_disabled { r#" disabled"# } else { "" },
        next_attr = if next_disabled { r#" disabled"# } else { "" },
        prev = encode_double_quoted_attribute(&prev_url),
        next = encode_double_quoted_attribute(&next_url),
        page = page,
        page_count = page_count,
    )
}

fn grid_url(query: &ContentGridQuery, page: usize) -> String {
    let mut params: Vec<String> = Vec::new();
    if let Some(tag) = &query.tag {
        params.push(format!("tag={}", urlencode(tag)));
    }
    if let Some(q) = &query.q {
        params.push(format!("q={}", urlencode(q)));
    }
    if page > 1 {
        params.push(format!("page={page}"));
    }
    if !query.language.is_empty() && query.language != "en" {
        params.push(format!("lang={}", query.language));
    }
    let qs = if params.is_empty() {
        String::new()
    } else {
        format!("?{}", params.join("&"))
    };
    format!("/api/htmx/content/{kind}{qs}", kind = query.kind)
}

fn urlencode(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for byte in s.bytes() {
        match byte {
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
                out.push(byte as char);
            }
            b' ' => out.push('+'),
            other => {
                out.push('%');
                out.push_str(&format!("{:02X}", other));
            }
        }
    }
    out
}

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

    fn sample_item(id: &str, title: &str, tags: &[&str]) -> UnifiedContentItem {
        UnifiedContentItem {
            id: id.into(),
            title: title.into(),
            slug: id.into(),
            language: "en".into(),
            content_type: "blog".into(),
            content: "".into(),
            excerpt: Some(format!("about {title}")),
            subtitle: None,
            categories: vec![],
            tags: tags.iter().map(|s| s.to_string()).collect(),
            emoji: None,
            featured: false,
            published: true,
            draft: Some(false),
            author: None,
            read_time: None,
            created_at: "2026-01-01".into(),
            updated_at: None,
            translations: vec![],
            translation_slugs: Default::default(),
            translation_ids: Default::default(),
            localized_slug: None,
            source_file: "".into(),
            metadata: Default::default(),
            difficulty: None,
            prep_time: None,
            duration: None,
            prerequisites: None,
            view_count: None,
            image_url: None,
            thumbnail: None,
            thumbnail_dark: None,
            page_route: Some(format!("/blog/{id}")),
            sort_order: 0,
        }
    }

    fn sample_index() -> ContentIndex {
        ContentIndex {
            items: vec![
                sample_item("a", "Rust performance", &["rust", "performance"]),
                sample_item("b", "Docker basics", &["docker", "devops"]),
                sample_item("c", "Async Rust", &["rust", "async"]),
            ],
            pages: vec![],
            language: "en".into(),
            content_type: "blog".into(),
        }
    }

    #[test]
    fn tag_filter_returns_subset() {
        let idx = sample_index();
        let q = ContentGridQuery {
            kind: "blog".into(),
            tag: Some("rust".into()),
            q: None,
            page: 1,
            language: "en".into(),
        };
        let filtered = apply_filters(&idx, &q);
        assert_eq!(filtered.len(), 2);
        assert!(filtered.iter().all(|i| i.tags.iter().any(|t| t == "rust")));
    }

    #[test]
    fn search_matches_title_substring() {
        let idx = sample_index();
        let q = ContentGridQuery {
            kind: "blog".into(),
            tag: None,
            q: Some("docker".into()),
            page: 1,
            language: "en".into(),
        };
        let filtered = apply_filters(&idx, &q);
        assert_eq!(filtered.len(), 1);
        assert_eq!(filtered[0].id, "b");
    }

    #[test]
    fn search_matches_excerpt_substring() {
        let idx = sample_index();
        let q = ContentGridQuery {
            kind: "blog".into(),
            tag: None,
            q: Some("about async".into()),
            page: 1,
            language: "en".into(),
        };
        let filtered = apply_filters(&idx, &q);
        assert_eq!(filtered.len(), 1);
    }

    #[test]
    fn unpublished_items_are_excluded() {
        let mut idx = sample_index();
        idx.items[0].published = false;
        let q = ContentGridQuery {
            kind: "blog".into(),
            tag: None,
            q: None,
            page: 1,
            language: "en".into(),
        };
        let filtered = apply_filters(&idx, &q);
        assert_eq!(filtered.len(), 2);
    }

    #[test]
    fn url_builder_omits_empty_params() {
        let q = ContentGridQuery {
            kind: "blog".into(),
            tag: None,
            q: None,
            page: 1,
            language: "en".into(),
        };
        assert_eq!(grid_url(&q, 1), "/api/htmx/content/blog");
    }

    #[test]
    fn url_builder_includes_set_params() {
        let q = ContentGridQuery {
            kind: "blog".into(),
            tag: Some("rust".into()),
            q: Some("async".into()),
            page: 1,
            language: "es".into(),
        };
        let url = grid_url(&q, 2);
        assert!(url.starts_with("/api/htmx/content/blog?"));
        assert!(url.contains("tag=rust"));
        assert!(url.contains("q=async"));
        assert!(url.contains("page=2"));
        assert!(url.contains("lang=es"));
    }

    #[test]
    fn urlencode_handles_special_chars() {
        assert_eq!(urlencode("hello world"), "hello+world");
        assert_eq!(urlencode("a/b"), "a%2Fb");
        assert_eq!(urlencode("rust-lang_2.0"), "rust-lang_2.0");
    }

    #[test]
    fn grid_body_renders_morph_container() {
        let item = sample_item("a", "Test", &["x"]);
        let q = ContentGridQuery {
            kind: "blog".into(),
            tag: None,
            q: None,
            page: 1,
            language: "en".into(),
        };
        let refs: Vec<&UnifiedContentItem> = vec![&item];
        let html = render_grid_body(&refs, &q, 1, 1, 1, "row");
        assert!(html.contains(r#"data-content-grid="true""#));
        assert!(html.contains(r#"id="content-grid-blog""#));
        assert!(html.contains(r#"id="card-a""#));
    }
}
