// synops-render — Tera HTML-rendering til CAS.
//
// Rendrer artikler, forsider og om-sider til HTML via Tera-templates,
// lagrer i CAS (content-addressable store), og oppdaterer node metadata.
// Erstatter rendering-logikken i maskinrommet/src/publishing.rs.
//
// Miljøvariabler:
//   DATABASE_URL  — PostgreSQL-tilkobling (påkrevd med --write)
//   CAS_ROOT      — Rot for content-addressable store (default: /srv/synops/media/cas)
//
// Ref: docs/retninger/unix_filosofi.md, docs/concepts/publisering.md

use chrono::{DateTime, Utc};
use clap::Parser;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::process;
use tera::{Context, Tera};
use uuid::Uuid;

/// Renderer-versjon. Økes ved mal-/template-endringer.
const RENDERER_VERSION: i64 = 2;

// =============================================================================
// CLI
// =============================================================================

/// Render artikkel/forside/om-side til HTML og lagre i CAS.
#[derive(Parser)]
#[command(name = "synops-render", about = "Tera HTML-rendering til CAS")]
struct Cli {
    /// Node-ID å rendere (artikkel eller samling)
    #[arg(long)]
    node_id: Uuid,

    /// Tema (avis, magasin, blogg, tidsskrift). Overstyrer samlingens konfig.
    #[arg(long)]
    theme: Option<String>,

    /// Samlings-ID (påkrevd for artikkel-rendering, brukes for tema/slug-oppslag)
    #[arg(long)]
    collection_id: Option<Uuid>,

    /// Render-type: article, index, about (default: article)
    #[arg(long, default_value = "article")]
    render_type: String,

    /// Bruker-ID som utløste renderingen (for ressurslogging)
    #[arg(long)]
    requested_by: Option<Uuid>,

    /// Skriv resultater til database (uten dette flagget: kun stdout)
    #[arg(long)]
    write: bool,
}

// =============================================================================
// Datamodeller
// =============================================================================

#[derive(Deserialize, Default, Debug)]
struct PublishingConfig {
    slug: Option<String>,
    theme: Option<String>,
    #[serde(default)]
    theme_config: ThemeConfig,
    custom_domain: Option<String>,
    featured_max: Option<i64>,
    stream_page_size: Option<i64>,
}

#[derive(Deserialize, Default, Debug, Clone, Serialize)]
struct ThemeConfig {
    #[serde(default)]
    colors: ColorConfig,
    #[serde(default)]
    typography: TypographyConfig,
    #[serde(default)]
    layout: LayoutConfig,
    logo_hash: Option<String>,
}

#[derive(Deserialize, Default, Debug, Clone, Serialize)]
struct ColorConfig {
    primary: Option<String>,
    accent: Option<String>,
    background: Option<String>,
    text: Option<String>,
    muted: Option<String>,
}

#[derive(Deserialize, Default, Debug, Clone, Serialize)]
struct TypographyConfig {
    heading_font: Option<String>,
    body_font: Option<String>,
}

#[derive(Deserialize, Default, Debug, Clone, Serialize)]
struct LayoutConfig {
    max_width: Option<String>,
}

#[derive(Serialize, Clone)]
struct ArticleData {
    id: String,
    short_id: String,
    title: String,
    subtitle: Option<String>,
    content: String,
    summary: Option<String>,
    og_image: Option<String>,
    published_at: String,
    published_at_short: String,
}

#[derive(Serialize)]
struct IndexData {
    title: String,
    description: Option<String>,
    hero: Option<ArticleData>,
    featured: Vec<ArticleData>,
    stream: Vec<ArticleData>,
}

#[derive(Serialize, Clone)]
struct SeoData {
    og_title: String,
    description: String,
    canonical_url: String,
    og_image: Option<String>,
    json_ld: String,
}

#[derive(Serialize)]
struct RenderResult {
    html_hash: String,
    size: u64,
    renderer_version: i64,
    render_type: String,
}

// =============================================================================
// Presentasjonselementer
// =============================================================================

struct PresEl {
    title: Option<String>,
    content: Option<String>,
    metadata: serde_json::Value,
    edge_metadata: serde_json::Value,
}

impl PresEl {
    fn ab_status(&self) -> &str {
        self.edge_metadata
            .get("ab_status")
            .and_then(|v| v.as_str())
            .unwrap_or("")
    }
}

struct PresentationElements {
    titles: Vec<PresEl>,
    subtitles: Vec<PresEl>,
    summaries: Vec<PresEl>,
    og_images: Vec<PresEl>,
}

impl PresentationElements {
    fn best_of(elements: &[PresEl]) -> Option<&PresEl> {
        if let Some(el) = elements.iter().find(|e| e.ab_status() == "winner") {
            return Some(el);
        }
        elements.iter().find(|e| e.ab_status() != "retired")
    }

    fn best_title(&self) -> Option<String> {
        Self::best_of(&self.titles)
            .and_then(|el| el.title.clone().or(el.content.clone()))
    }

    fn best_subtitle(&self) -> Option<String> {
        Self::best_of(&self.subtitles)
            .and_then(|el| el.title.clone().or(el.content.clone()))
    }

    fn best_summary(&self) -> Option<String> {
        Self::best_of(&self.summaries)
            .and_then(|el| el.content.clone().or(el.title.clone()))
    }

    fn best_og_image(&self) -> Option<String> {
        Self::best_of(&self.og_images)
            .and_then(|el| el.metadata.get("cas_hash").and_then(|h| h.as_str()).map(|s| s.to_string()))
    }
}

// =============================================================================
// Tema-defaults og CSS-variabler
// =============================================================================

struct ThemeDefaults {
    primary: &'static str,
    accent: &'static str,
    background: &'static str,
    text: &'static str,
    muted: &'static str,
    heading_font: &'static str,
    body_font: &'static str,
    max_width: &'static str,
}

fn theme_defaults(theme: &str) -> ThemeDefaults {
    match theme {
        "avis" => ThemeDefaults {
            primary: "#1a1a2e",
            accent: "#e94560",
            background: "#ffffff",
            text: "#1a1a2e",
            muted: "#6b7280",
            heading_font: "'Georgia', 'Times New Roman', serif",
            body_font: "'Charter', 'Georgia', serif",
            max_width: "1200px",
        },
        "magasin" => ThemeDefaults {
            primary: "#2d3436",
            accent: "#0984e3",
            background: "#fafafa",
            text: "#2d3436",
            muted: "#636e72",
            heading_font: "'Playfair Display', 'Georgia', serif",
            body_font: "system-ui, -apple-system, sans-serif",
            max_width: "1100px",
        },
        "blogg" => ThemeDefaults {
            primary: "#2c3e50",
            accent: "#3498db",
            background: "#ffffff",
            text: "#333333",
            muted: "#7f8c8d",
            heading_font: "system-ui, -apple-system, sans-serif",
            body_font: "system-ui, -apple-system, sans-serif",
            max_width: "720px",
        },
        "tidsskrift" => ThemeDefaults {
            primary: "#1a1a1a",
            accent: "#8b0000",
            background: "#fffff8",
            text: "#1a1a1a",
            muted: "#555555",
            heading_font: "'Georgia', 'Times New Roman', serif",
            body_font: "'Georgia', 'Times New Roman', serif",
            max_width: "680px",
        },
        _ => theme_defaults("blogg"),
    }
}

fn build_css_variables(theme: &str, config: &ThemeConfig) -> String {
    let defaults = theme_defaults(theme);
    format!(
        r#":root {{
  --color-primary: {primary};
  --color-accent: {accent};
  --color-background: {background};
  --color-text: {text};
  --color-muted: {muted};
  --font-heading: {heading_font};
  --font-body: {body_font};
  --layout-max-width: {max_width};
}}"#,
        primary = config.colors.primary.as_deref().unwrap_or(defaults.primary),
        accent = config.colors.accent.as_deref().unwrap_or(defaults.accent),
        background = config.colors.background.as_deref().unwrap_or(defaults.background),
        text = config.colors.text.as_deref().unwrap_or(defaults.text),
        muted = config.colors.muted.as_deref().unwrap_or(defaults.muted),
        heading_font = config.typography.heading_font.as_deref().unwrap_or(defaults.heading_font),
        body_font = config.typography.body_font.as_deref().unwrap_or(defaults.body_font),
        max_width = config.layout.max_width.as_deref().unwrap_or(defaults.max_width),
    )
}

// =============================================================================
// Tera-templates (innebygde)
// =============================================================================

fn build_tera() -> Tera {
    let mut tera = Tera::default();

    tera.add_raw_template("base.html", include_str!("templates/base.html"))
        .expect("Feil i base.html template");

    tera.add_raw_template("avis/article.html", include_str!("templates/avis/article.html"))
        .expect("Feil i avis/article.html");
    tera.add_raw_template("avis/index.html", include_str!("templates/avis/index.html"))
        .expect("Feil i avis/index.html");

    tera.add_raw_template("magasin/article.html", include_str!("templates/magasin/article.html"))
        .expect("Feil i magasin/article.html");
    tera.add_raw_template("magasin/index.html", include_str!("templates/magasin/index.html"))
        .expect("Feil i magasin/index.html");

    tera.add_raw_template("blogg/article.html", include_str!("templates/blogg/article.html"))
        .expect("Feil i blogg/article.html");
    tera.add_raw_template("blogg/index.html", include_str!("templates/blogg/index.html"))
        .expect("Feil i blogg/index.html");

    tera.add_raw_template("tidsskrift/article.html", include_str!("templates/tidsskrift/article.html"))
        .expect("Feil i tidsskrift/article.html");
    tera.add_raw_template("tidsskrift/index.html", include_str!("templates/tidsskrift/index.html"))
        .expect("Feil i tidsskrift/index.html");

    tera.add_raw_template("category.html", include_str!("templates/category.html"))
        .expect("Feil i category.html");
    tera.add_raw_template("archive.html", include_str!("templates/archive.html"))
        .expect("Feil i archive.html");
    tera.add_raw_template("search.html", include_str!("templates/search.html"))
        .expect("Feil i search.html");
    tera.add_raw_template("about.html", include_str!("templates/about.html"))
        .expect("Feil i about.html");

    tera
}

// =============================================================================
// SEO
// =============================================================================

fn build_seo_data(
    article: &ArticleData,
    collection_title: &str,
    canonical_url: &str,
) -> SeoData {
    let description = article.summary.as_deref().unwrap_or("").to_string();
    let json_ld = build_json_ld(article, collection_title, canonical_url);
    let og_image = article.og_image.as_ref().map(|hash| format!("/cas/{hash}"));

    SeoData {
        og_title: article.title.clone(),
        description,
        canonical_url: canonical_url.to_string(),
        og_image,
        json_ld,
    }
}

fn build_json_ld(
    article: &ArticleData,
    publisher_name: &str,
    canonical_url: &str,
) -> String {
    let ld = serde_json::json!({
        "@context": "https://schema.org",
        "@type": "Article",
        "headline": article.title,
        "datePublished": article.published_at,
        "url": canonical_url,
        "publisher": {
            "@type": "Organization",
            "name": publisher_name
        },
        "description": article.summary.as_deref().unwrap_or("")
    });
    ld.to_string()
}

// =============================================================================
// TipTap JSON → HTML
// =============================================================================

mod tiptap;

// =============================================================================
// CAS-lagring
// =============================================================================

async fn store_in_cas(root: &str, data: &[u8]) -> Result<(String, u64, bool), String> {
    let hash = synops_common::cas::hash_bytes(data);
    let size = data.len() as u64;
    let path = synops_common::cas::path(root, &hash);

    if path.exists() {
        return Ok((hash, size, true));
    }

    if let Some(parent) = path.parent() {
        tokio::fs::create_dir_all(parent)
            .await
            .map_err(|e| format!("Kunne ikke opprette CAS-katalog: {e}"))?;
    }

    // Skriv via temp-fil for atomisk operasjon
    let tmp_dir = PathBuf::from(root).join("tmp");
    tokio::fs::create_dir_all(&tmp_dir)
        .await
        .map_err(|e| format!("Kunne ikke opprette CAS tmp-katalog: {e}"))?;
    let tmp_path = tmp_dir.join(format!("{hash}.tmp"));

    tokio::fs::write(&tmp_path, data)
        .await
        .map_err(|e| format!("Kunne ikke skrive til CAS tmp-fil: {e}"))?;

    tokio::fs::rename(&tmp_path, &path)
        .await
        .map_err(|e| format!("Atomisk rename feilet: {e}"))?;

    Ok((hash, size, false))
}

// =============================================================================
// Hjelpefunksjoner
// =============================================================================

fn truncate(s: &str, max: usize) -> String {
    if s.len() <= max {
        return s.to_string();
    }
    match s[..max].rfind(' ') {
        Some(pos) => format!("{}…", &s[..pos]),
        None => format!("{}…", &s[..max]),
    }
}

// =============================================================================
// Render-logikk
// =============================================================================

fn render_article_html(
    tera: &Tera,
    theme: &str,
    config: &ThemeConfig,
    article: &ArticleData,
    collection_title: &str,
    base_url: &str,
    seo: &SeoData,
    has_rss: bool,
) -> Result<String, String> {
    let css_vars = build_css_variables(theme, config);
    let template_name = format!("{theme}/article.html");

    let mut ctx = Context::new();
    ctx.insert("css_variables", &css_vars);
    ctx.insert("theme", theme);
    ctx.insert("article", article);
    ctx.insert("collection_title", collection_title);
    ctx.insert("base_url", base_url);
    ctx.insert("logo_hash", &config.logo_hash);
    ctx.insert("seo", seo);
    ctx.insert("has_rss", &has_rss);

    tera.render(&template_name, &ctx)
        .map_err(|e| format!("Tera render-feil: {e}"))
}

fn render_index_html(
    tera: &Tera,
    theme: &str,
    config: &ThemeConfig,
    index: &IndexData,
    base_url: &str,
    has_rss: bool,
) -> Result<String, String> {
    let css_vars = build_css_variables(theme, config);
    let template_name = format!("{theme}/index.html");

    let mut ctx = Context::new();
    ctx.insert("css_variables", &css_vars);
    ctx.insert("theme", theme);
    ctx.insert("index", index);
    ctx.insert("collection_title", &index.title);
    ctx.insert("base_url", base_url);
    ctx.insert("logo_hash", &config.logo_hash);
    ctx.insert("has_rss", &has_rss);

    tera.render(&template_name, &ctx)
        .map_err(|e| format!("Tera render-feil (index): {e}"))
}

// =============================================================================
// Database-oppslag
// =============================================================================

async fn fetch_collection_config(
    db: &sqlx::PgPool,
    collection_id: Uuid,
) -> Result<(String, PublishingConfig, bool), String> {
    let row: Option<(Option<String>, serde_json::Value)> = sqlx::query_as(
        r#"
        SELECT title, metadata
        FROM nodes
        WHERE id = $1 AND node_kind = 'collection'
        "#,
    )
    .bind(collection_id)
    .fetch_optional(db)
    .await
    .map_err(|e| format!("Feil ved henting av samling: {e}"))?;

    let Some((title_opt, metadata)) = row else {
        return Err(format!("Samling {collection_id} finnes ikke"));
    };

    let traits = metadata.get("traits");

    let config: PublishingConfig = traits
        .and_then(|t| t.get("publishing"))
        .cloned()
        .map(|v| serde_json::from_value(v).unwrap_or_default())
        .unwrap_or_default();

    let has_rss = traits.and_then(|t| t.get("rss")).is_some();

    let slug = config.slug.as_deref().unwrap_or("unknown");
    let title = title_opt.unwrap_or_else(|| slug.to_string());

    Ok((title, config, has_rss))
}

/// Finn samlings-ID for en artikkel via belongs_to-edge.
async fn find_collection_for_article(
    db: &sqlx::PgPool,
    node_id: Uuid,
) -> Result<Option<Uuid>, String> {
    let row: Option<(Uuid,)> = sqlx::query_as(
        r#"
        SELECT e.target_id
        FROM edges e
        JOIN nodes n ON n.id = e.target_id AND n.node_kind = 'collection'
        WHERE e.source_id = $1
          AND e.edge_type = 'belongs_to'
          AND n.metadata->'traits' ? 'publishing'
        LIMIT 1
        "#,
    )
    .bind(node_id)
    .fetch_optional(db)
    .await
    .map_err(|e| format!("Feil ved oppslag av samling for artikkel: {e}"))?;

    Ok(row.map(|(id,)| id))
}

async fn fetch_presentation_elements(
    db: &sqlx::PgPool,
    article_id: Uuid,
) -> Result<PresentationElements, String> {
    let rows: Vec<(String, Option<String>, Option<String>, serde_json::Value, serde_json::Value)> = sqlx::query_as(
        r#"
        SELECT e.edge_type, n.title, n.content, n.metadata, e.metadata AS edge_metadata
        FROM edges e
        JOIN nodes n ON n.id = e.source_id
        WHERE e.target_id = $1
          AND e.edge_type IN ('title', 'subtitle', 'summary', 'og_image')
        ORDER BY e.created_at
        "#,
    )
    .bind(article_id)
    .fetch_all(db)
    .await
    .map_err(|e| format!("Feil ved henting av presentasjonselementer: {e}"))?;

    let mut titles = vec![];
    let mut subtitles = vec![];
    let mut summaries = vec![];
    let mut og_images = vec![];

    for (edge_type, title, content, metadata, edge_metadata) in rows {
        let el = PresEl { title, content, metadata, edge_metadata };
        match edge_type.as_str() {
            "title" => titles.push(el),
            "subtitle" => subtitles.push(el),
            "summary" => summaries.push(el),
            "og_image" => og_images.push(el),
            _ => {}
        }
    }

    Ok(PresentationElements { titles, subtitles, summaries, og_images })
}

// =============================================================================
// Artikkel-rendering
// =============================================================================

async fn render_article_to_cas(
    db: &sqlx::PgPool,
    cas_root: &str,
    node_id: Uuid,
    collection_id: Uuid,
    theme_override: Option<&str>,
    write: bool,
) -> Result<RenderResult, String> {
    let (collection_title, pub_config, has_rss) =
        fetch_collection_config(db, collection_id).await?;

    let theme = theme_override
        .or(pub_config.theme.as_deref())
        .unwrap_or("blogg");
    let config = &pub_config.theme_config;
    let slug = pub_config.slug.as_deref().unwrap_or("unknown");

    // Hent artikkeldata
    let article_row: Option<(Uuid, Option<String>, Option<String>, serde_json::Value, DateTime<Utc>)> = sqlx::query_as(
        "SELECT id, title, content, metadata, created_at FROM nodes WHERE id = $1",
    )
    .bind(node_id)
    .fetch_optional(db)
    .await
    .map_err(|e| format!("Feil ved henting av artikkel: {e}"))?;

    let Some((id, title, content, metadata, created_at)) = article_row else {
        return Err(format!("Artikkel {node_id} finnes ikke"));
    };

    // Hent publish_at fra edge-metadata
    let edge_meta: Option<(Option<serde_json::Value>,)> = sqlx::query_as(
        r#"
        SELECT metadata FROM edges
        WHERE source_id = $1 AND target_id = $2 AND edge_type = 'belongs_to'
        LIMIT 1
        "#,
    )
    .bind(node_id)
    .bind(collection_id)
    .fetch_optional(db)
    .await
    .map_err(|e| format!("Feil ved henting av edge: {e}"))?;

    let publish_at = edge_meta
        .as_ref()
        .and_then(|(m,)| m.as_ref())
        .and_then(|m| m.get("publish_at"))
        .and_then(|v| v.as_str())
        .and_then(|s| s.parse::<DateTime<Utc>>().ok())
        .unwrap_or(created_at);

    // Konverter TipTap JSON → HTML
    let article_html = if let Some(doc) = metadata.get("document") {
        let html = tiptap::document_to_html(doc);
        if html.is_empty() {
            content.unwrap_or_default()
        } else {
            html
        }
    } else {
        content.unwrap_or_default()
    };

    let short_id = id.to_string()[..8].to_string();

    // Hent presentasjonselementer
    let pres = fetch_presentation_elements(db, node_id).await?;

    let article_title = pres.best_title()
        .unwrap_or_else(|| title.unwrap_or_else(|| "Uten tittel".to_string()));

    let summary_text = pres.best_summary()
        .unwrap_or_else(|| truncate(&article_html.replace("<p>", "").replace("</p>", " ").replace('\n', " "), 200));

    let article_data = ArticleData {
        id: id.to_string(),
        short_id: short_id.clone(),
        title: article_title,
        subtitle: pres.best_subtitle(),
        content: article_html,
        summary: Some(summary_text),
        og_image: pres.best_og_image(),
        published_at: publish_at.to_rfc3339(),
        published_at_short: publish_at.format("%e. %B %Y").to_string(),
    };

    // SEO + render
    let base_url = pub_config
        .custom_domain
        .as_deref()
        .map(|d| format!("https://{d}"))
        .unwrap_or_else(|| format!("/pub/{slug}"));

    let canonical_url = format!("{base_url}/{short_id}");
    let seo = build_seo_data(&article_data, &collection_title, &canonical_url);

    let tera = build_tera();
    let html = render_article_html(&tera, theme, config, &article_data, &collection_title, &base_url, &seo, has_rss)?;

    // Lagre i CAS
    let (hash, size, deduplicated) = store_in_cas(cas_root, html.as_bytes()).await?;

    tracing::info!(
        node_id = %node_id,
        hash = %hash,
        size = size,
        deduplicated = deduplicated,
        "Artikkel rendret og lagret i CAS"
    );

    // Oppdater metadata i PG
    if write {
        let now = Utc::now();
        sqlx::query(
            r#"
            UPDATE nodes
            SET metadata = jsonb_set(
                jsonb_set(
                    jsonb_set(
                        CASE WHEN metadata ? 'rendered'
                            THEN metadata
                            ELSE jsonb_set(metadata, '{rendered}', '{}'::jsonb)
                        END,
                        '{rendered,html_hash}',
                        to_jsonb($2::text)
                    ),
                    '{rendered,rendered_at}',
                    to_jsonb($3::text)
                ),
                '{rendered,renderer_version}',
                to_jsonb($4::bigint)
            )
            WHERE id = $1
            "#,
        )
        .bind(node_id)
        .bind(&hash)
        .bind(now.to_rfc3339())
        .bind(RENDERER_VERSION)
        .execute(db)
        .await
        .map_err(|e| format!("Feil ved oppdatering av metadata.rendered: {e}"))?;

        tracing::info!(node_id = %node_id, html_hash = %hash, "metadata.rendered oppdatert");

        // Ressurslogging
        log_resource_usage(db, node_id, size, "render_article").await;
    }

    Ok(RenderResult {
        html_hash: hash,
        size,
        renderer_version: RENDERER_VERSION,
        render_type: "article".to_string(),
    })
}

// =============================================================================
// Index-rendering
// =============================================================================

async fn render_index_to_cas(
    db: &sqlx::PgPool,
    cas_root: &str,
    collection_id: Uuid,
    theme_override: Option<&str>,
    write: bool,
) -> Result<RenderResult, String> {
    let (collection_title, pub_config, has_rss) =
        fetch_collection_config(db, collection_id).await?;

    let theme = theme_override
        .or(pub_config.theme.as_deref())
        .unwrap_or("blogg");
    let config = &pub_config.theme_config;
    let slug = pub_config.slug.as_deref().unwrap_or("unknown");
    let featured_max = pub_config.featured_max.unwrap_or(4);
    let stream_page_size = pub_config.stream_page_size.unwrap_or(20);

    let base_url = pub_config
        .custom_domain
        .as_deref()
        .map(|d| format!("https://{d}"))
        .unwrap_or_else(|| format!("/pub/{slug}"));

    // Hent artikler for forsiden
    let articles = fetch_index_articles(db, collection_id, featured_max, stream_page_size).await?;

    let (hero, featured, stream) = categorize_articles(articles);

    let index_data = IndexData {
        title: collection_title,
        description: None,
        hero,
        featured,
        stream,
    };

    let tera = build_tera();
    let html = render_index_html(&tera, theme, config, &index_data, &base_url, has_rss)?;

    let (hash, size, deduplicated) = store_in_cas(cas_root, html.as_bytes()).await?;

    tracing::info!(
        collection_id = %collection_id,
        hash = %hash,
        size = size,
        deduplicated = deduplicated,
        "Forside rendret og lagret i CAS"
    );

    if write {
        let now = Utc::now();
        sqlx::query(
            r#"
            UPDATE nodes
            SET metadata = jsonb_set(
                jsonb_set(
                    jsonb_set(
                        CASE WHEN metadata ? 'rendered_index'
                            THEN metadata
                            ELSE jsonb_set(metadata, '{rendered_index}', '{}'::jsonb)
                        END,
                        '{rendered_index,index_hash}',
                        to_jsonb($2::text)
                    ),
                    '{rendered_index,rendered_at}',
                    to_jsonb($3::text)
                ),
                '{rendered_index,renderer_version}',
                to_jsonb($4::bigint)
            )
            WHERE id = $1
            "#,
        )
        .bind(collection_id)
        .bind(&hash)
        .bind(now.to_rfc3339())
        .bind(RENDERER_VERSION)
        .execute(db)
        .await
        .map_err(|e| format!("Feil ved oppdatering av metadata.rendered_index: {e}"))?;

        tracing::info!(collection_id = %collection_id, index_hash = %hash, "metadata.rendered_index oppdatert");

        log_resource_usage(db, collection_id, size, "render_index").await;
    }

    Ok(RenderResult {
        html_hash: hash,
        size,
        renderer_version: RENDERER_VERSION,
        render_type: "index".to_string(),
    })
}

/// Hent publiserte artikler for en samling, sortert etter publish_at desc.
async fn fetch_index_articles(
    db: &sqlx::PgPool,
    collection_id: Uuid,
    featured_max: i64,
    stream_page_size: i64,
) -> Result<Vec<(ArticleData, Option<String>)>, String> {
    // Hent artikler med slot-metadata fra edge
    let limit = 1 + featured_max + stream_page_size;
    let rows: Vec<(Uuid, Option<String>, Option<String>, serde_json::Value, DateTime<Utc>, Option<serde_json::Value>)> = sqlx::query_as(
        r#"
        SELECT n.id, n.title, n.content, n.metadata, n.created_at, e.metadata AS edge_metadata
        FROM edges e
        JOIN nodes n ON n.id = e.source_id
        WHERE e.target_id = $1
          AND e.edge_type = 'belongs_to'
          AND n.node_kind IN ('article', 'note')
        ORDER BY
          COALESCE((e.metadata->>'publish_at')::timestamptz, n.created_at) DESC
        LIMIT $2
        "#,
    )
    .bind(collection_id)
    .bind(limit)
    .fetch_all(db)
    .await
    .map_err(|e| format!("Feil ved henting av forsideartikler: {e}"))?;

    let mut articles = Vec::new();
    for (id, title, content, metadata, created_at, edge_metadata) in rows {
        let publish_at = edge_metadata
            .as_ref()
            .and_then(|m| m.get("publish_at"))
            .and_then(|v| v.as_str())
            .and_then(|s| s.parse::<DateTime<Utc>>().ok())
            .unwrap_or(created_at);

        let slot = edge_metadata
            .as_ref()
            .and_then(|m| m.get("slot"))
            .and_then(|v| v.as_str())
            .map(|s| s.to_string());

        let article_html = if let Some(doc) = metadata.get("document") {
            let html = tiptap::document_to_html(doc);
            if html.is_empty() { content.unwrap_or_default() } else { html }
        } else {
            content.unwrap_or_default()
        };

        let short_id = id.to_string()[..8].to_string();
        let summary_text = truncate(
            &article_html.replace("<p>", "").replace("</p>", " ").replace('\n', " "),
            200,
        );

        let article_data = ArticleData {
            id: id.to_string(),
            short_id,
            title: title.unwrap_or_else(|| "Uten tittel".to_string()),
            subtitle: None,
            content: article_html,
            summary: Some(summary_text),
            og_image: None,
            published_at: publish_at.to_rfc3339(),
            published_at_short: publish_at.format("%e. %B %Y").to_string(),
        };

        articles.push((article_data, slot));
    }

    Ok(articles)
}

/// Fordel artikler i hero/featured/stream basert på slot-metadata.
fn categorize_articles(
    articles: Vec<(ArticleData, Option<String>)>,
) -> (Option<ArticleData>, Vec<ArticleData>, Vec<ArticleData>) {
    let mut hero: Option<ArticleData> = None;
    let mut featured = Vec::new();
    let mut stream = Vec::new();

    for (article, slot) in articles {
        match slot.as_deref() {
            Some("hero") if hero.is_none() => hero = Some(article),
            Some("featured") => featured.push(article),
            _ => stream.push(article),
        }
    }

    // Hvis ingen hero er satt, bruk første stream-artikkel
    if hero.is_none() && !stream.is_empty() {
        hero = Some(stream.remove(0));
    }

    (hero, featured, stream)
}

// =============================================================================
// Ressurslogging
// =============================================================================

async fn log_resource_usage(db: &sqlx::PgPool, node_id: Uuid, bytes: u64, operation: &str) {
    let _ = sqlx::query(
        r#"
        INSERT INTO resource_usage_log (node_id, resource_type, amount, unit, metadata)
        VALUES ($1, $2, $3, 'bytes', $4)
        "#,
    )
    .bind(node_id)
    .bind(operation)
    .bind(bytes as i64)
    .bind(serde_json::json!({"renderer_version": RENDERER_VERSION}))
    .execute(db)
    .await;
}

// =============================================================================
// main
// =============================================================================

#[tokio::main]
async fn main() {
    synops_common::logging::init("synops_render");

    let cli = Cli::parse();

    // Valider render_type
    if !["article", "index"].contains(&cli.render_type.as_str()) {
        eprintln!("Ugyldig render-type: {}. Bruk: article, index", cli.render_type);
        process::exit(1);
    }

    // Koble til database
    let db = synops_common::db::connect().await.unwrap_or_else(|e| {
        eprintln!("{e}");
        process::exit(1);
    });

    let cas_root = synops_common::cas::root();

    let result = match cli.render_type.as_str() {
        "article" => {
            // Finn collection_id: brukt direkte, eller oppslag via belongs_to
            let collection_id = match cli.collection_id {
                Some(id) => id,
                None => {
                    match find_collection_for_article(&db, cli.node_id).await {
                        Ok(Some(id)) => id,
                        Ok(None) => {
                            eprintln!("Ingen publishing-samling funnet for node {}. Bruk --collection-id.", cli.node_id);
                            process::exit(1);
                        }
                        Err(e) => {
                            eprintln!("{e}");
                            process::exit(1);
                        }
                    }
                }
            };

            render_article_to_cas(
                &db,
                &cas_root,
                cli.node_id,
                collection_id,
                cli.theme.as_deref(),
                cli.write,
            )
            .await
        }
        "index" => {
            render_index_to_cas(
                &db,
                &cas_root,
                cli.node_id,
                cli.theme.as_deref(),
                cli.write,
            )
            .await
        }
        _ => unreachable!(),
    };

    match result {
        Ok(res) => {
            let json = serde_json::to_string_pretty(&res).unwrap();
            println!("{json}");
        }
        Err(e) => {
            eprintln!("Render feilet: {e}");
            process::exit(1);
        }
    }
}

// =============================================================================
// Tester
// =============================================================================

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

    #[test]
    fn test_build_css_variables_defaults() {
        let config = ThemeConfig::default();
        let css = build_css_variables("blogg", &config);
        assert!(css.contains("--color-primary: #2c3e50"));
        assert!(css.contains("--color-accent: #3498db"));
        assert!(css.contains("--layout-max-width: 720px"));
    }

    #[test]
    fn test_build_css_variables_override() {
        let config = ThemeConfig {
            colors: ColorConfig {
                primary: Some("#ff0000".to_string()),
                ..Default::default()
            },
            ..Default::default()
        };
        let css = build_css_variables("blogg", &config);
        assert!(css.contains("--color-primary: #ff0000"));
        // Andre verdier bruker defaults
        assert!(css.contains("--color-accent: #3498db"));
    }

    #[test]
    fn test_theme_defaults_fallback() {
        let defaults = theme_defaults("ukjent_tema");
        // Skal falle tilbake til blogg
        assert_eq!(defaults.primary, "#2c3e50");
    }

    #[test]
    fn test_truncate() {
        assert_eq!(truncate("kort", 10), "kort");
        assert_eq!(truncate("dette er en lang tekst som skal kuttes", 20), "dette er en lang…");
    }

    #[test]
    fn test_cas_path() {
        let path = synops_common::cas::path("/srv/synops/media/cas", "b94d27b9934d3e08");
        assert!(path.to_string_lossy().contains("/b9/4d/"));
    }

    #[test]
    fn test_build_seo_data() {
        let article = ArticleData {
            id: "test-id".to_string(),
            short_id: "test-sho".to_string(),
            title: "Testittel".to_string(),
            subtitle: None,
            content: "<p>Innhold</p>".to_string(),
            summary: Some("Sammendrag".to_string()),
            og_image: Some("abc123".to_string()),
            published_at: "2024-01-01T00:00:00Z".to_string(),
            published_at_short: " 1. January 2024".to_string(),
        };
        let seo = build_seo_data(&article, "Testpub", "/pub/test/test-sho");
        assert_eq!(seo.og_title, "Testittel");
        assert_eq!(seo.description, "Sammendrag");
        assert!(seo.json_ld.contains("\"@type\":\"Article\""));
        assert_eq!(seo.og_image, Some("/cas/abc123".to_string()));
    }

    #[test]
    fn test_categorize_articles() {
        let hero_art = ArticleData {
            id: "1".into(), short_id: "1".into(), title: "Hero".into(),
            subtitle: None, content: "".into(), summary: None, og_image: None,
            published_at: "".into(), published_at_short: "".into(),
        };
        let feat_art = ArticleData {
            id: "2".into(), short_id: "2".into(), title: "Featured".into(),
            subtitle: None, content: "".into(), summary: None, og_image: None,
            published_at: "".into(), published_at_short: "".into(),
        };
        let stream_art = ArticleData {
            id: "3".into(), short_id: "3".into(), title: "Stream".into(),
            subtitle: None, content: "".into(), summary: None, og_image: None,
            published_at: "".into(), published_at_short: "".into(),
        };

        let articles = vec![
            (hero_art, Some("hero".to_string())),
            (feat_art, Some("featured".to_string())),
            (stream_art, None),
        ];

        let (hero, featured, stream) = categorize_articles(articles);
        assert!(hero.is_some());
        assert_eq!(hero.unwrap().title, "Hero");
        assert_eq!(featured.len(), 1);
        assert_eq!(stream.len(), 1);
    }

    #[test]
    fn test_categorize_articles_auto_hero() {
        let art = ArticleData {
            id: "1".into(), short_id: "1".into(), title: "Auto hero".into(),
            subtitle: None, content: "".into(), summary: None, og_image: None,
            published_at: "".into(), published_at_short: "".into(),
        };
        let articles = vec![(art, None)];
        let (hero, _, stream) = categorize_articles(articles);
        assert!(hero.is_some());
        assert_eq!(hero.unwrap().title, "Auto hero");
        assert!(stream.is_empty());
    }

    #[test]
    fn test_render_article_html() {
        let tera = build_tera();
        let config = ThemeConfig::default();
        let article = ArticleData {
            id: "test".into(), short_id: "test".into(), title: "Test".into(),
            subtitle: None, content: "<p>Hello</p>".into(),
            summary: Some("Sum".into()), og_image: None,
            published_at: "2024-01-01T00:00:00Z".into(),
            published_at_short: "1. jan 2024".into(),
        };
        let seo = build_seo_data(&article, "Pub", "/pub/test/test");
        let html = render_article_html(&tera, "blogg", &config, &article, "Pub", "/pub/test", &seo, false);
        assert!(html.is_ok());
        let html = html.unwrap();
        assert!(html.contains("Test")); // tittel
        assert!(html.contains("<p>Hello</p>")); // innhold
        assert!(html.contains("--color-primary")); // CSS-variabler
    }

    #[test]
    fn test_render_index_html() {
        let tera = build_tera();
        let config = ThemeConfig::default();
        let index = IndexData {
            title: "Forside".to_string(),
            description: None,
            hero: Some(ArticleData {
                id: "1".into(), short_id: "1".into(), title: "Hero".into(),
                subtitle: None, content: "<p>Hero content</p>".into(),
                summary: Some("Hero sum".into()), og_image: None,
                published_at: "2024-01-01T00:00:00Z".into(),
                published_at_short: "1. jan".into(),
            }),
            featured: vec![],
            stream: vec![],
        };
        let html = render_index_html(&tera, "blogg", &config, &index, "/pub/test", false);
        assert!(html.is_ok());
        let html = html.unwrap();
        assert!(html.contains("Hero"));
        assert!(html.contains("Forside"));
    }

    #[test]
    fn test_presentation_elements_empty() {
        let pres = PresentationElements {
            titles: vec![], subtitles: vec![], summaries: vec![], og_images: vec![],
        };
        assert!(pres.best_title().is_none());
        assert!(pres.best_subtitle().is_none());
        assert!(pres.best_summary().is_none());
        assert!(pres.best_og_image().is_none());
    }
}
