// synops-ai — LLM-verktøy for Synops-plattformen.
//
// To moduser:
//   1. `prompt`:  Direkte LLM-kall via LiteLLM — prompt inn, tekst ut.
//   2. `script`:  AI-assistert oppretting av orkestreringsscript (oppgave 24.7).
//
// Miljøvariabler:
//   DATABASE_URL       — PostgreSQL-tilkobling (påkrevd)
//   AI_GATEWAY_URL     — LiteLLM gateway (default: http://localhost:4000)
//   LITELLM_MASTER_KEY — API-nøkkel for LiteLLM

use clap::{Parser, Subcommand};
use serde::{Deserialize, Serialize};
use std::process;
use uuid::Uuid;

/// synops-ai — LLM-verktøy for Synops
#[derive(Parser)]
#[command(name = "synops-ai")]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Direkte LLM-kall: prompt inn, tekst ut
    Prompt(PromptArgs),
    /// Generer orkestreringsscript fra fritekst-beskrivelse
    Script(ScriptArgs),
}

#[derive(Parser)]
struct PromptArgs {
    /// Bruker-prompt (påkrevd)
    #[arg(long)]
    prompt: String,

    /// Modellalias (f.eks. "synops/low"). Hvis utelatt, slås opp fra ai_job_routing.
    #[arg(long)]
    model: Option<String>,

    /// Systemprompt (valgfri)
    #[arg(long)]
    system: Option<String>,

    /// Bilde-fil å inkludere som visuell kontekst (multimodal/vision).
    /// Bildet sendes som base64 data-URL til LLM.
    #[arg(long)]
    image: Option<String>,

    /// MIME-type for bildet (default: image/png)
    #[arg(long, default_value = "image/png")]
    image_mime: String,

    /// Job-type for modelloppslag og logging (default: "simple_prompt")
    #[arg(long, default_value = "simple_prompt")]
    job_type: String,

    /// Temperatur (0.0–2.0, default: 0.7)
    #[arg(long, default_value = "0.7")]
    temperature: f32,

    /// Samlings-ID for budsjettsjekk og logging
    #[arg(long)]
    collection_id: Option<Uuid>,

    /// Bruker-ID som utløste kallet (for budsjettsjekk og logging)
    #[arg(long)]
    user_id: Option<Uuid>,
}

#[derive(Parser)]
struct ScriptArgs {
    /// Fritekst-beskrivelse av ønsket orkestrering
    #[arg(long)]
    description: Option<String>,

    /// Trigger-event for scriptet (f.eks. "communication.ended")
    #[arg(long)]
    trigger_event: Option<String>,

    /// Trigger-betingelser som JSON
    #[arg(long)]
    trigger_conditions: Option<String>,

    /// Kun skriv ut auto-generert systemprompt (ingen LLM-kall)
    #[arg(long)]
    generate_system_prompt: bool,

    /// Eventually-modus: lagre som work_item
    #[arg(long)]
    eventually: bool,

    /// Bruker-ID som utløste forespørselen (påkrevd for --eventually)
    #[arg(long)]
    requested_by: Option<Uuid>,

    /// Samlings-ID å knytte work_item til
    #[arg(long)]
    collection_id: Option<Uuid>,
}

// --- LLM request/response (OpenAI-kompatibel) ---

#[derive(Serialize)]
struct ChatRequest {
    model: String,
    messages: Vec<ChatMessage>,
    temperature: f32,
}

#[derive(Serialize, Clone)]
struct ChatMessage {
    role: String,
    content: MessageContent,
}

/// Meldingsinnhold: enten en enkel streng eller multimodal (tekst + bilde).
/// OpenAI-kompatibelt format: string | array<{type, text?, image_url?}>
#[derive(Clone)]
enum MessageContent {
    Text(String),
    Multimodal(Vec<ContentPart>),
}

#[derive(Serialize, Clone)]
#[serde(tag = "type")]
enum ContentPart {
    #[serde(rename = "text")]
    Text { text: String },
    #[serde(rename = "image_url")]
    ImageUrl { image_url: ImageUrlDetail },
}

#[derive(Serialize, Clone)]
struct ImageUrlDetail {
    url: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    detail: Option<String>,
}

impl Serialize for MessageContent {
    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        match self {
            MessageContent::Text(s) => serializer.serialize_str(s),
            MessageContent::Multimodal(parts) => parts.serialize(serializer),
        }
    }
}

#[derive(Deserialize)]
struct ChatResponse {
    choices: Vec<Choice>,
    #[serde(default)]
    usage: Option<UsageInfo>,
    #[serde(default)]
    model: Option<String>,
}

#[derive(Deserialize, Clone)]
struct UsageInfo {
    #[serde(default)]
    prompt_tokens: i64,
    #[serde(default)]
    completion_tokens: i64,
}

#[derive(Deserialize)]
struct Choice {
    message: ResponseMessage,
}

#[derive(Deserialize)]
struct ResponseMessage {
    content: Option<String>,
}

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

    let cli = Cli::parse();

    let result = match cli.command {
        Command::Prompt(args) => run_prompt(args).await,
        Command::Script(args) => run_script(args).await,
    };

    if let Err(e) = result {
        eprintln!("Feil: {e}");
        process::exit(1);
    }
}

// ============================================================
// Prompt-modus (oppgave 28.1): direkte LLM-kall
// ============================================================

async fn run_prompt(args: PromptArgs) -> Result<(), String> {
    let db = synops_common::db::connect().await?;

    // Bestem modell: eksplisitt flag → ai_job_routing → fallback
    let model_alias = match &args.model {
        Some(m) => m.clone(),
        None => resolve_model(&db, &args.job_type).await?,
    };

    // Sjekk budsjett før LLM-kall
    if let Some(collection_id) = args.collection_id {
        if let Err(msg) = check_budget(&db, collection_id, "collection").await {
            create_budget_work_item(&db, collection_id, args.user_id, &msg).await;
            return Err(msg);
        }
    }
    if let Some(user_id) = args.user_id {
        if let Err(msg) = check_budget(&db, user_id, "user").await {
            let coll = args.collection_id.unwrap_or(user_id);
            create_budget_work_item(&db, coll, Some(user_id), &msg).await;
            return Err(msg);
        }
    }

    tracing::info!(
        model = %model_alias,
        job_type = %args.job_type,
        prompt_len = args.prompt.len(),
        "Sender prompt til LLM"
    );

    // Bygg meldinger
    let mut messages = Vec::new();
    if let Some(ref sys) = args.system {
        messages.push(ChatMessage {
            role: "system".to_string(),
            content: MessageContent::Text(sys.clone()),
        });
    }

    // Multimodal melding hvis --image er oppgitt
    let user_content = if let Some(ref image_path) = args.image {
        let image_data = std::fs::read(image_path)
            .map_err(|e| format!("Kunne ikke lese bildefil '{}': {e}", image_path))?;

        use base64::Engine;
        let b64 = base64::engine::general_purpose::STANDARD.encode(&image_data);
        let data_url = format!("data:{};base64,{}", args.image_mime, b64);

        tracing::info!(
            image_path,
            image_size = image_data.len(),
            mime = %args.image_mime,
            "Inkluderer bilde i multimodal melding"
        );

        MessageContent::Multimodal(vec![
            ContentPart::Text { text: args.prompt.clone() },
            ContentPart::ImageUrl {
                image_url: ImageUrlDetail {
                    url: data_url,
                    detail: Some("auto".to_string()),
                },
            },
        ])
    } else {
        MessageContent::Text(args.prompt.clone())
    };

    messages.push(ChatMessage {
        role: "user".to_string(),
        content: user_content,
    });

    let (content, usage, actual_model) =
        call_llm_with(&model_alias, &messages, args.temperature).await?;

    // Skriv svar til stdout
    println!("{content}");

    // Logg i ai_usage_log (inkludert collection_node_id og requested_by)
    let tokens_in = usage.as_ref().map(|u| u.prompt_tokens).unwrap_or(0);
    let tokens_out = usage.as_ref().map(|u| u.completion_tokens).unwrap_or(0);
    let actual = actual_model.as_deref().unwrap_or("unknown");

    if let Err(e) = sqlx::query(
        "INSERT INTO ai_usage_log (collection_node_id, requested_by, model_alias, model_actual, prompt_tokens, completion_tokens, total_tokens, job_type)
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
    )
    .bind(args.collection_id)
    .bind(args.user_id)
    .bind(&model_alias)
    .bind(actual)
    .bind(tokens_in as i32)
    .bind(tokens_out as i32)
    .bind((tokens_in + tokens_out) as i32)
    .bind(&args.job_type)
    .execute(&db)
    .await
    {
        tracing::warn!(error = %e, "Kunne ikke logge AI-bruk i ai_usage_log");
    }

    tracing::info!(
        model_alias = %model_alias,
        model_actual = %actual,
        tokens_in,
        tokens_out,
        "LLM-kall fullført"
    );

    Ok(())
}

// ============================================================
// Budsjettsjekk (oppgave 28.3)
// ============================================================

/// Estimerer kostnad fra tokens (samme formel som maskinrommet/metrics.rs).
/// Input: $3/MTok, Output: $15/MTok (konservativt gjennomsnitt).
fn estimate_cost_usd(prompt_tokens: i64, completion_tokens: i64) -> f64 {
    let input_cost = prompt_tokens as f64 * 3.0 / 1_000_000.0;
    let output_cost = completion_tokens as f64 * 15.0 / 1_000_000.0;
    ((input_cost + output_cost) * 100.0).round() / 100.0
}

/// Sjekker ai_budget for en node (samling eller bruker).
///
/// Leser `metadata.ai_budget.monthly_limit_usd` fra noden,
/// aggregerer denne månedens forbruk fra ai_usage_log,
/// og returnerer Err med feilmelding hvis budsjettet er overskredet.
async fn check_budget(
    db: &sqlx::PgPool,
    node_id: Uuid,
    node_type: &str, // "collection" eller "user"
) -> Result<(), String> {
    // Hent metadata fra noden
    let metadata: Option<serde_json::Value> =
        sqlx::query_scalar("SELECT metadata FROM nodes WHERE id = $1")
            .bind(node_id)
            .fetch_optional(db)
            .await
            .map_err(|e| format!("PG-feil ved budsjettsjekk: {e}"))?;

    let monthly_limit = metadata
        .as_ref()
        .and_then(|m| m.get("ai_budget"))
        .and_then(|b| b.get("monthly_limit_usd"))
        .and_then(|v| v.as_f64());

    let limit = match monthly_limit {
        Some(l) => l,
        None => return Ok(()), // Ingen budsjettgrense satt
    };

    // Aggreger denne månedens forbruk
    let (total_prompt, total_completion) = match node_type {
        "collection" => {
            sqlx::query_as::<_, (i64, i64)>(
                r#"SELECT
                    COALESCE(SUM(prompt_tokens)::BIGINT, 0),
                    COALESCE(SUM(completion_tokens)::BIGINT, 0)
                FROM ai_usage_log
                WHERE collection_node_id = $1
                  AND created_at >= date_trunc('month', now())"#,
            )
            .bind(node_id)
            .fetch_one(db)
            .await
            .map_err(|e| format!("PG-feil ved aggregering av forbruk: {e}"))?
        }
        "user" => {
            sqlx::query_as::<_, (i64, i64)>(
                r#"SELECT
                    COALESCE(SUM(prompt_tokens)::BIGINT, 0),
                    COALESCE(SUM(completion_tokens)::BIGINT, 0)
                FROM ai_usage_log
                WHERE requested_by = $1
                  AND created_at >= date_trunc('month', now())"#,
            )
            .bind(node_id)
            .fetch_one(db)
            .await
            .map_err(|e| format!("PG-feil ved aggregering av brukerforbruk: {e}"))?
        }
        _ => return Ok(()),
    };

    let current_cost = estimate_cost_usd(total_prompt, total_completion);

    if current_cost >= limit {
        let node_title: Option<String> =
            sqlx::query_scalar("SELECT title FROM nodes WHERE id = $1")
                .bind(node_id)
                .fetch_optional(db)
                .await
                .ok()
                .flatten();

        let name = node_title.unwrap_or_else(|| node_id.to_string());
        return Err(format!(
            "AI-budsjett overskredet for {node_type} \"{name}\": \
             forbruk ${current_cost:.2} >= grense ${limit:.2} denne måneden"
        ));
    }

    tracing::debug!(
        node_id = %node_id,
        node_type,
        current_cost,
        limit,
        "Budsjettsjekk OK"
    );

    Ok(())
}

/// Oppretter work_item-node når budsjett er overskredet.
async fn create_budget_work_item(
    db: &sqlx::PgPool,
    collection_id: Uuid,
    user_id: Option<Uuid>,
    error_msg: &str,
) {
    let work_item_id = Uuid::now_v7();
    let title = format!("AI-budsjett overskredet");
    let created_by = user_id.unwrap_or(collection_id);

    let metadata = serde_json::json!({
        "work_item_type": "budget_exceeded",
        "collection_id": collection_id.to_string(),
        "error": error_msg,
    });

    // Opprett work_item-node
    if let Err(e) = sqlx::query(
        r#"INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
        VALUES ($1, 'content', $2, $3, 'hidden'::visibility, $4, $5)"#,
    )
    .bind(work_item_id)
    .bind(&title)
    .bind(error_msg)
    .bind(&metadata)
    .bind(created_by)
    .execute(db)
    .await
    {
        tracing::warn!(error = %e, "Kunne ikke opprette work_item for budsjettoverskridelse");
        return;
    }

    // tagged-edge: budget_exceeded
    let tag_edge_id = Uuid::now_v7();
    if let Err(e) = sqlx::query(
        r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
        VALUES ($1, $2, $2, 'tagged', '{"tag": "budget_exceeded"}'::jsonb, true, $3)"#,
    )
    .bind(tag_edge_id)
    .bind(work_item_id)
    .bind(created_by)
    .execute(db)
    .await
    {
        tracing::warn!(error = %e, "Kunne ikke opprette tagged-edge for work_item");
    }

    // belongs_to-edge til samlingen
    let belongs_edge_id = Uuid::now_v7();
    if let Err(e) = sqlx::query(
        r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
        VALUES ($1, $2, $3, 'belongs_to', '{}'::jsonb, true, $4)"#,
    )
    .bind(belongs_edge_id)
    .bind(work_item_id)
    .bind(collection_id)
    .bind(created_by)
    .execute(db)
    .await
    {
        tracing::warn!(error = %e, "Kunne ikke opprette belongs_to-edge for work_item");
    }

    tracing::info!(
        work_item_id = %work_item_id,
        collection_id = %collection_id,
        "Work item opprettet for budsjettoverskridelse"
    );
}

/// Slå opp modellalias fra ai_job_routing. Fallback: synops/low.
async fn resolve_model(db: &sqlx::PgPool, job_type: &str) -> Result<String, String> {
    let row: Option<(String,)> =
        sqlx::query_as("SELECT alias FROM ai_job_routing WHERE job_type = $1")
            .bind(job_type)
            .fetch_optional(db)
            .await
            .map_err(|e| format!("PG-feil ved oppslag i ai_job_routing: {e}"))?;

    match row {
        Some((alias,)) => {
            tracing::info!(job_type, alias = %alias, "Modell funnet i ai_job_routing");
            Ok(alias)
        }
        None => {
            let fallback = "synops/low".to_string();
            tracing::info!(
                job_type,
                fallback = %fallback,
                "Ingen routing funnet, bruker fallback"
            );
            Ok(fallback)
        }
    }
}

/// Generisk LLM-kall mot LiteLLM gateway.
async fn call_llm_with(
    model: &str,
    messages: &[ChatMessage],
    temperature: f32,
) -> Result<(String, Option<UsageInfo>, Option<String>), String> {
    let gateway_url =
        std::env::var("AI_GATEWAY_URL").unwrap_or_else(|_| "http://localhost:4000".to_string());
    let api_key = std::env::var("LITELLM_MASTER_KEY").unwrap_or_default();

    let request = ChatRequest {
        model: model.to_string(),
        messages: messages.to_vec(),
        temperature,
    };

    let client = reqwest::Client::new();
    let url = format!("{gateway_url}/v1/chat/completions");

    let resp = client
        .post(&url)
        .header("Authorization", format!("Bearer {api_key}"))
        .header("Content-Type", "application/json")
        .json(&request)
        .timeout(std::time::Duration::from_secs(120))
        .send()
        .await
        .map_err(|e| format!("LiteLLM-kall feilet: {e}"))?;

    if !resp.status().is_success() {
        let status = resp.status();
        let body = resp.text().await.unwrap_or_default();
        return Err(format!("LiteLLM returnerte {status}: {body}"));
    }

    let chat_resp: ChatResponse = resp
        .json()
        .await
        .map_err(|e| format!("Kunne ikke parse LiteLLM-respons: {e}"))?;

    let content = chat_resp
        .choices
        .first()
        .and_then(|c| c.message.content.as_deref())
        .ok_or("LiteLLM returnerte ingen content")?
        .to_string();

    Ok((content, chat_resp.usage, chat_resp.model))
}

// ============================================================
// Script-modus (oppgave 24.7): orkestreringsscript-generering
// ============================================================

// --- cli_tool metadata fra PG ---

#[derive(sqlx::FromRow)]
struct CliToolRow {
    #[allow(dead_code)]
    title: Option<String>,
    metadata: Option<serde_json::Value>,
}

struct ToolInfo {
    binary: String,
    description: String,
    aliases: Vec<String>,
    args_hints: Vec<(String, String)>,
}

fn extract_tool_info(row: &CliToolRow) -> Option<ToolInfo> {
    let meta = row.metadata.as_ref()?;
    let binary = meta.get("binary")?.as_str()?.to_string();
    let description = meta
        .get("description")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();
    let aliases: Vec<String> = meta
        .get("aliases")
        .and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|v| v.as_str().map(|s| s.to_string()))
                .collect()
        })
        .unwrap_or_default();
    let args_hints: Vec<(String, String)> = meta
        .get("args_hints")
        .and_then(|v| v.as_object())
        .map(|obj| {
            obj.iter()
                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
                .collect()
        })
        .unwrap_or_default();
    Some(ToolInfo {
        binary,
        description,
        aliases,
        args_hints,
    })
}

fn build_system_prompt(tools: &[ToolInfo]) -> String {
    let mut prompt = String::new();

    prompt.push_str(
        "Du er en orkestreringsplanlegger for Synops, en norsk redaksjonsplattform.\n\
         \n\
         Du genererer orkestreringsscript i Synops sitt menneskelige scriptspråk.\n\
         Scriptet kompileres automatisk til CLI-kall av vaktmesteren.\n\
         \n\
         VIKTIG: Skriv scriptet i det menneskelige laget — bruk norske verb og \n\
         argumenter i parenteser. IKKE skriv CLI-flagg eller teknisk syntax.\n\
         \n",
    );

    prompt.push_str("TILGJENGELIGE VERKTØY:\n\n");
    for tool in tools {
        prompt.push_str(&format!("- {}: {}\n", tool.binary, tool.description));
        if !tool.aliases.is_empty() {
            prompt.push_str(&format!("  Verb: {}\n", tool.aliases.join(", ")));
        }
        if !tool.args_hints.is_empty() {
            prompt.push_str("  Argumenter (bruk i parenteser):\n");
            for (human, _technical) in &tool.args_hints {
                prompt.push_str(&format!("    - \"{human}\"\n"));
            }
        }
        prompt.push('\n');
    }

    prompt.push_str(
        "SCRIPT-GRAMMATIKK (menneskelig lag):\n\
         \n\
         ```\n\
         NÅR <event i naturlig språk>\n\
         HVIS <betingelse i naturlig språk>\n\
         \n\
         <N>. <verb> <objekt> [(<argument1>, <argument2>)]\n\
         <N+1>. <verb> <objekt>\n\
         \n\
         ved feil: opprett oppgave \"<tittel>\" (<tag>)\n\
         ```\n\
         \n\
         Regler:\n\
         - NÅR og HVIS er valgfrie (trigger settes separat i metadata)\n\
         - Verb må matche ett av verktøyenes alias-verb\n\
         - Argumenter i parentes må matche verktøyets argumenter nøyaktig\n\
         - Hvert steg nummereres sekvensielt (1, 2, 3...)\n\
         - Steg-spesifikk feilhåndtering med innrykk:\n\
         ```\n\
         1. transkriber lydfilen (stor modell)\n\
         \x20  ved feil: transkriber lydfilen (medium modell)\n\
         ```\n\
         - Global feilhåndtering (uten innrykk) på siste linje\n\
         - \"opprett oppgave\" er alltid tilgjengelig for feilhåndtering\n\
         \n",
    );

    prompt.push_str(
        "KJENTE TRIGGER-EVENTS:\n\
         - node.created — Ny node opprettet\n\
         - edge.created — Ny edge opprettet\n\
         - communication.ended — Samtale/innspilling avsluttet\n\
         - node.published — Node publisert\n\
         - scheduled.due — Planlagt tidspunkt nådd\n\
         - manual — Bruker trykker \"Kjør\"\n\
         \n",
    );

    prompt.push_str(
        "EKSEMPEL:\n\
         Beskrivelse: \"Når en innspilling er ferdig, transkriber og oppsummer\"\n\
         \n\
         Resultat:\n\
         ```\n\
         1. transkriber lydfilen (stor modell)\n\
         \x20  ved feil: transkriber lydfilen (medium modell)\n\
         2. oppsummer samtalen\n\
         \n\
         ved feil: opprett oppgave \"Pipeline feilet\" (bug)\n\
         ```\n\
         \n\
         EKSEMPEL 2:\n\
         Beskrivelse: \"Publiser artikkelen og oppdater RSS\"\n\
         \n\
         Resultat:\n\
         ```\n\
         1. render artikkelen\n\
         2. oppdater rss-feed\n\
         \n\
         ved feil: opprett oppgave \"Publisering feilet\" (bug)\n\
         ```\n\
         \n",
    );

    prompt.push_str(
        "INSTRUKSJONER:\n\
         1. Les brukerens beskrivelse nøye.\n\
         2. Velg de riktige verktøyene og argumentene.\n\
         3. Skriv et gyldig script i det menneskelige laget.\n\
         4. Bruk feilhåndtering der det er naturlig (VED_FEIL eller fallback-modell).\n\
         5. Svar KUN med scriptet — ingen forklaringer, ingen markdown-blokker, \n\
            ingen kommentarer. Bare rene script-linjer.\n\
         6. Hvis beskrivelsen refererer til funksjonalitet som IKKE finnes i \n\
            tilgjengelige verktøy, bruk verbet/objektet likevel — kompilatoren \n\
            vil rapportere feilen, og brukeren kan opprette en forespørsel.\n",
    );

    prompt
}

async fn run_script(args: ScriptArgs) -> Result<(), String> {
    if !args.generate_system_prompt && args.description.is_none() {
        return Err("--description eller --generate-system-prompt er påkrevd".into());
    }

    if args.eventually && args.requested_by.is_none() {
        return Err("--requested-by er påkrevd sammen med --eventually".into());
    }

    let db = synops_common::db::connect().await?;

    let rows = sqlx::query_as::<_, CliToolRow>(
        "SELECT title, metadata FROM nodes WHERE node_kind = 'cli_tool' ORDER BY title",
    )
    .fetch_all(&db)
    .await
    .map_err(|e| format!("PG-feil ved henting av cli_tool-noder: {e}"))?;

    let tools: Vec<ToolInfo> = rows.iter().filter_map(extract_tool_info).collect();
    tracing::info!(tool_count = tools.len(), "Hentet cli_tool-noder");

    let system_prompt = build_system_prompt(&tools);

    if args.generate_system_prompt {
        println!("{system_prompt}");
        return Ok(());
    }

    let description = args.description.as_deref().unwrap();

    if args.eventually {
        return save_work_item(
            &db,
            description,
            args.trigger_event.as_deref(),
            args.trigger_conditions.as_deref(),
            args.requested_by.unwrap(),
            args.collection_id,
        )
        .await;
    }

    // Synkron script-generering
    let user_content = build_user_prompt(
        description,
        args.trigger_event.as_deref(),
        args.trigger_conditions.as_deref(),
    );

    tracing::info!(description_len = description.len(), "Sender til LLM for script-generering");

    let model =
        std::env::var("AI_SCRIPT_MODEL").unwrap_or_else(|_| "synops/low".to_string());

    let messages = vec![
        ChatMessage {
            role: "system".to_string(),
            content: MessageContent::Text(system_prompt),
        },
        ChatMessage {
            role: "user".to_string(),
            content: MessageContent::Text(user_content),
        },
    ];

    let (generated_script, llm_usage, llm_model) =
        call_llm_with(&model, &messages, 0.3).await?;

    // Strip eventuelle markdown-blokker
    let generated_script = strip_markdown_fences(&generated_script);

    tracing::info!(script_len = generated_script.len(), "Script generert fra LLM");

    let has_numbered_steps = generated_script.lines().any(|line| {
        let trimmed = line.trim();
        trimmed.len() > 2
            && trimmed.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false)
            && trimmed.contains('.')
    });

    let tokens_in = llm_usage.as_ref().map(|u| u.prompt_tokens).unwrap_or(0);
    let tokens_out = llm_usage.as_ref().map(|u| u.completion_tokens).unwrap_or(0);
    let model_id = llm_model.unwrap_or_else(|| "unknown".to_string());

    if let Err(e) = sqlx::query(
        "INSERT INTO resource_usage_log (target_node_id, triggered_by, resource_type, detail)
         VALUES ($1, $2, $3, $4)",
    )
    .bind(args.collection_id)
    .bind(args.requested_by)
    .bind("ai")
    .bind(serde_json::json!({
        "model_id": model_id,
        "tokens_in": tokens_in,
        "tokens_out": tokens_out,
        "job_type": "orchestration_script_generation"
    }))
    .execute(&db)
    .await
    {
        tracing::warn!(error = %e, "Kunne ikke logge AI-ressursforbruk");
    }

    let result = serde_json::json!({
        "status": "completed",
        "script": generated_script.trim(),
        "has_steps": has_numbered_steps,
        "model": model_id,
        "tokens_in": tokens_in,
        "tokens_out": tokens_out,
        "tool_count": tools.len(),
    });

    println!(
        "{}",
        serde_json::to_string_pretty(&result)
            .map_err(|e| format!("JSON-serialisering feilet: {e}"))?
    );

    Ok(())
}

fn build_user_prompt(
    description: &str,
    trigger_event: Option<&str>,
    trigger_conditions: Option<&str>,
) -> String {
    let mut prompt = format!("Lag et orkestreringsscript for: {description}");
    if let Some(event) = trigger_event {
        prompt.push_str(&format!("\n\nTrigger-event: {event}"));
    }
    if let Some(conditions) = trigger_conditions {
        prompt.push_str(&format!("\nBetingelser: {conditions}"));
    }
    prompt
}

fn strip_markdown_fences(text: &str) -> String {
    let trimmed = text.trim();
    if trimmed.starts_with("```") {
        let lines: Vec<&str> = trimmed.lines().collect();
        if lines.len() >= 2 {
            let start = 1;
            let end = if lines.last().map(|l| l.trim()) == Some("```") {
                lines.len() - 1
            } else {
                lines.len()
            };
            return lines[start..end].join("\n");
        }
    }
    trimmed.to_string()
}

async fn save_work_item(
    db: &sqlx::PgPool,
    description: &str,
    trigger_event: Option<&str>,
    trigger_conditions: Option<&str>,
    requested_by: Uuid,
    collection_id: Option<Uuid>,
) -> Result<(), String> {
    let work_item_id = Uuid::now_v7();
    let title = format!("AI-script: {}", truncate(description, 80));

    let metadata = serde_json::json!({
        "work_item_type": "script_request",
        "description": description,
        "trigger_event": trigger_event,
        "trigger_conditions": trigger_conditions,
    });

    sqlx::query(
        r#"
        INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
        VALUES ($1, 'content', $2, $3, 'hidden'::visibility, $4, $5)
        "#,
    )
    .bind(work_item_id)
    .bind(&title)
    .bind(description)
    .bind(&metadata)
    .bind(requested_by)
    .execute(db)
    .await
    .map_err(|e| format!("PG insert work_item feilet: {e}"))?;

    let tag_edge_id = Uuid::now_v7();
    sqlx::query(
        r#"
        INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
        VALUES ($1, $2, $2, 'tagged', '{"tag": "script_request"}'::jsonb, true, $3)
        "#,
    )
    .bind(tag_edge_id)
    .bind(work_item_id)
    .bind(requested_by)
    .execute(db)
    .await
    .map_err(|e| format!("PG insert tagged-edge feilet: {e}"))?;

    if let Some(coll_id) = collection_id {
        let belongs_edge_id = Uuid::now_v7();
        sqlx::query(
            r#"
            INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
            VALUES ($1, $2, $3, 'belongs_to', '{}'::jsonb, false, $4)
            "#,
        )
        .bind(belongs_edge_id)
        .bind(work_item_id)
        .bind(coll_id)
        .bind(requested_by)
        .execute(db)
        .await
        .map_err(|e| format!("PG insert belongs_to-edge feilet: {e}"))?;
    }

    tracing::info!(
        work_item_id = %work_item_id,
        "Work item opprettet for script-forespørsel"
    );

    let result = serde_json::json!({
        "status": "deferred",
        "work_item_id": work_item_id.to_string(),
        "title": title,
        "message": "Forespørselen er lagret som work_item. Claude Code vil generere scriptet i neste sesjon.",
    });

    println!(
        "{}",
        serde_json::to_string_pretty(&result)
            .map_err(|e| format!("JSON-serialisering feilet: {e}"))?
    );

    Ok(())
}

fn truncate(s: &str, max_len: usize) -> &str {
    if s.len() <= max_len {
        s
    } else {
        let mut end = max_len;
        while end > 0 && !s.is_char_boundary(end) {
            end -= 1;
        }
        &s[..end]
    }
}
