//! Builds the [`PageSeo`] descriptor consumed by both shells.
//!
//! Resolution order, in priority:
//! 1. Generated route component (title/description/keywords from FTL keys).
//! 2. Site defaults from `site/config/site.ncl` (site name, default locale).
//! 3. Hreflang alternates derived from configured languages.

use rustelo_core_lib::config::get_supported_languages;
use rustelo_seo::{
    join_url, HreflangAlt, OgImage, OgType, OpenGraph, PageSeo, RobotsDirective, TwitterCard,
    TwitterCardKind,
};
use rustelo_web::rustelo_core_lib::i18n::{PageTranslator, SsrTranslator};

/// Build a [`PageSeo`] for the requested path + language.
///
/// `base_url` is the fully-qualified site origin (e.g. `https://example.com`)
/// used for canonical and hreflang URLs.
pub fn build_page_seo(path: &str, language: &str, base_url: &str) -> PageSeo {
    let component = {
        let base = crate::resolve_component_for_path(path);
        let with_page = crate::generated::RouteComponent::from_str(&format!("{}Page", base));
        if with_page != crate::generated::RouteComponent::NotFound || base == "NotFound" {
            with_page
        } else {
            crate::generated::RouteComponent::from_str(base)
        }
    };

    let translator = SsrTranslator::new(language.to_string());
    let raw_title = component.title_key();
    let translated_title = translator.t(raw_title);
    let mut title = if translated_title.is_empty() {
        raw_title.to_string()
    } else {
        translated_title
    };
    // ContentIndex is shared across kinds, so its component title is generic. Resolve
    // a per-kind index title from the URL prefix (e.g. /blog -> blog-page-title) so
    // /blog and /projects get distinct titles instead of one generic ContentIndex one.
    if raw_title == "contentindex-page-title" {
        if let Some(kind_title) = content_index_title(path, language, &translator) {
            title = kind_title;
        }
    }
    // Content-detail routes bind the served item's frontmatter (from the generated index
    // JSON) into the <head>, overriding the component-static values. Closes the
    // post-detail-head-title-unbound gap (ontoref rustelo publish-contract).
    //
    // Read ONCE: resolving the item walks every kind dir, so a second lookup for the image
    // would double that work on every request for a value already in hand.
    let item = content_item(path, language);
    let title = item
        .as_ref()
        .and_then(|i| non_empty_str(i, "title"))
        .unwrap_or(title);
    let raw_desc = component.description_key();
    let translated_desc = translator.t(raw_desc);
    let description = if translated_desc.is_empty() || translated_desc == raw_desc {
        None
    } else {
        Some(translated_desc)
    };
    let raw_keywords = component.keywords_key();
    let translated_keywords = translator.t(raw_keywords);
    let keywords = if translated_keywords.is_empty() || translated_keywords == raw_keywords {
        Vec::new()
    } else {
        translated_keywords
            .split(',')
            .map(|s| s.trim().to_string())
            .filter(|s| !s.is_empty())
            .collect::<Vec<_>>()
    };

    let site_name = std::env::var("SITE_NAME").unwrap_or_else(|_| "Rustelo".to_string());
    let title_with_site = if title.contains(&site_name) || title.is_empty() {
        title.clone()
    } else {
        format!("{site_name} — {title}")
    };

    let canonical_path = canonical_path_for(path, language);
    let canonical = join_url(base_url, &canonical_path);

    let hreflang = build_hreflang(path, base_url);

    // The card an item declares, else the site default. It is an explicit `og_image`, NOT the
    // item's `image_url`: that one is the .webp the <picture> serves, and the crawlers that
    // matter (X, LinkedIn, WhatsApp) do not decode WebP — binding it here would trade a generic
    // card for a BLANK one. `og_image` is a PNG sized for the card, and its absence is not a
    // failure: falling back to the site default is the honest answer for a page that has no art.
    let og_image_url = item
        .as_ref()
        .and_then(|i| non_empty_str(i, "og_image"))
        .map(|p| join_url(base_url, &p))
        .unwrap_or_else(|| join_url(base_url, "/images/og/default.png"));

    let og = OpenGraph {
        title: title_with_site.clone(),
        description: description.clone(),
        og_type: OgType::Website,
        url: canonical.clone(),
        image: Some(OgImage::new(og_image_url)),
        site_name: Some(site_name.clone()),
        locale: Some(locale_for(language)),
    };
    let twitter = TwitterCard {
        card: TwitterCardKind::SummaryLargeImage,
        title: title_with_site.clone(),
        description: description.clone(),
        image: og.image.as_ref().map(|i| i.url.clone()),
        // The card carries the page's own art, so the page's own title describes it. An unlabelled
        // image is unreadable to a screen reader, and "" says nothing a crawler can use.
        image_alt: Some(title_with_site.clone()),
        site: None,
        creator: None,
    };

    PageSeo {
        title: title_with_site,
        description,
        keywords,
        language: language.to_string(),
        canonical,
        hreflang,
        og,
        twitter,
        robots: RobotsDirective::default(),
        jsonld: Vec::new(),
    }
}

/// For a ContentIndex route, resolve a per-kind index title from the URL prefix:
/// the first path segment after the language maps to the `<segment>-page-title` FTL
/// key (e.g. `/blog` -> `blog-page-title`, `/es/recetas` -> `recetas-page-title`).
/// Returns `None` when the key is absent so the caller keeps the generic title.
fn content_index_title(path: &str, language: &str, translator: &SsrTranslator) -> Option<String> {
    let trimmed = path.trim_start_matches('/');
    let after_lang = trimmed
        .strip_prefix(&format!("{language}/"))
        .unwrap_or(trimmed);
    let segment = after_lang.split('/').find(|s| !s.is_empty())?;
    let key = format!("{segment}-page-title");
    let translated = translator.t(&key);
    if translated.is_empty() || translated == key {
        None
    } else {
        Some(translated)
    }
}

/// A frontmatter string field, absent when missing, non-string, or empty. An empty `og_image`
/// must read as "no card declared" and fall back — not as a card whose URL is the empty string.
fn non_empty_str(item: &serde_json::Value, key: &str) -> Option<String> {
    item.get(key)
        .and_then(|v| v.as_str())
        .filter(|s| !s.is_empty())
        .map(|s| s.to_string())
}

/// The served item's frontmatter, as the generated index declares it. Returns the whole record so
/// the caller reads every field it needs from ONE resolution.
///
/// Resolved by the item's OWN `slug`, out of `<kind>/<language>/index.json` — never by guessing a
/// file path from the URL. Two independent facts make the path guess wrong, and both were silently
/// dropping every page back to the site-wide title and card:
///
///   • the URL carries no category (`/blog/<slug>`) while the file sits under one
///     (`blog/en/ontoref/<slug>.json`), so the direct lookup missed;
///   • a file's NAME is not its slug — `expedientes/en/cases/recaida-hardcoded-bis.json` is served
///     at `/case-files/the-relapse-hardcoded`. The slug is the published identity, the filename is
///     an authoring detail, and only one of them is in the URL.
///
/// The index is the one place where the mapping is stated rather than inferred, so it is the only
/// honest thing to ask.
fn content_item(path: &str, language: &str) -> Option<serde_json::Value> {
    let root = std::env::var("SITE_SERVER_CONTENT_ROOT").ok()?;
    let trimmed = path.trim_start_matches('/');
    let after_lang = trimmed
        .strip_prefix(&format!("{language}/"))
        .unwrap_or(trimmed);
    let segments: Vec<&str> = after_lang.split('/').filter(|s| !s.is_empty()).collect();
    // A detail route is a kind and a slug at minimum; a bare `/blog` is an index, not an item.
    if segments.len() < 2 {
        return None;
    }
    let slug = *segments.last()?;

    for kind in std::fs::read_dir(&root).ok()?.flatten() {
        if !kind.path().is_dir() {
            continue;
        }
        let index = kind.path().join(language).join("index.json");
        let Ok(text) = std::fs::read_to_string(&index) else {
            continue;
        };
        let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
            continue;
        };
        if let Some(found) = value
            .get("posts")
            .and_then(|p| p.as_array())
            .and_then(|posts| {
                posts
                    .iter()
                    .find(|item| non_empty_str(item, "slug").as_deref() == Some(slug))
            })
        {
            return Some(found.clone());
        }
    }
    None
}

fn canonical_path_for(path: &str, language: &str) -> String {
    if language == "en" {
        path.to_string()
    } else {
        let trimmed = path.trim_start_matches('/');
        if trimmed.starts_with(&format!("{language}/")) || trimmed == language {
            path.to_string()
        } else {
            format!("/{language}{path}")
        }
    }
}

fn build_hreflang(path: &str, base_url: &str) -> Vec<HreflangAlt> {
    let langs = get_supported_languages();
    let mut out: Vec<HreflangAlt> = Vec::new();
    for lang in langs.iter() {
        let p = canonical_path_for(path, lang);
        out.push(HreflangAlt {
            lang: lang.to_string(),
            href: join_url(base_url, &p),
        });
    }
    out.push(HreflangAlt {
        lang: "x-default".into(),
        href: join_url(base_url, path),
    });
    out
}

fn locale_for(language: &str) -> String {
    match language {
        "en" => "en_US".into(),
        "es" => "es_ES".into(),
        other => other.replace('-', "_"),
    }
}

/// Returns the configured public base URL.
///
/// Reads `SITE_BASE_URL`; falls back to `http://127.0.0.1:3030` for local dev
/// so canonical links don't end up empty in development.
pub fn site_base_url() -> String {
    std::env::var("SITE_BASE_URL").unwrap_or_else(|_| "http://127.0.0.1:3030".to_string())
}

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

    #[test]
    fn canonical_path_passthrough_for_default_language() {
        assert_eq!(canonical_path_for("/about", "en"), "/about");
    }

    #[test]
    fn canonical_path_prefixes_non_default_language() {
        assert_eq!(canonical_path_for("/about", "es"), "/es/about");
    }

    #[test]
    fn canonical_path_no_double_prefix() {
        assert_eq!(canonical_path_for("/es/about", "es"), "/es/about");
    }

    #[test]
    fn locale_mapping() {
        assert_eq!(locale_for("en"), "en_US");
        assert_eq!(locale_for("es"), "es_ES");
        assert_eq!(locale_for("pt-BR"), "pt_BR");
    }

    #[test]
    fn og_image_read_from_frontmatter() {
        let item = serde_json::json!({ "og_image": "/images/og/the-relapse.png" });
        assert_eq!(
            non_empty_str(&item, "og_image").as_deref(),
            Some("/images/og/the-relapse.png")
        );
    }

    #[test]
    fn blank_og_image_falls_back_rather_than_emitting_an_empty_url() {
        // The whole point of the filter: "" must read as "no card declared". Without it the
        // <head> would ship `og:image=""` — a card the crawler tries, and fails, to fetch.
        for blank in [serde_json::json!({ "og_image": "" }), serde_json::json!({})] {
            assert_eq!(non_empty_str(&blank, "og_image"), None);
        }
    }

    #[test]
    fn item_is_matched_by_its_slug_not_by_a_filename() {
        // The regression this whole change turns on: the expediente served at
        // /case-files/the-relapse-hardcoded is authored in recaida-hardcoded-bis.json. Match on the
        // declared slug and it resolves; match on the filename and every card falls back forever.
        let index = serde_json::json!({
            "posts": [
                { "slug": "the-blind-witness",    "og_image": "/images/og/a.png" },
                { "slug": "the-relapse-hardcoded", "og_image": "/images/og/b.png" },
            ]
        });
        let found = index
            .get("posts")
            .and_then(|p| p.as_array())
            .and_then(|posts| {
                posts
                    .iter()
                    .find(|i| non_empty_str(i, "slug").as_deref() == Some("the-relapse-hardcoded"))
            })
            .expect("the slug is in the index");
        assert_eq!(
            non_empty_str(found, "og_image").as_deref(),
            Some("/images/og/b.png")
        );
    }

    #[test]
    fn og_image_is_absolute_against_the_site_origin() {
        // Crawlers do not resolve a relative og:image — the URL must carry the origin.
        assert_eq!(
            join_url("https://ontoref.dev", "/images/og/default.png"),
            "https://ontoref.dev/images/og/default.png"
        );
    }

}
