// Intensjoner — skrivestien i maskinrommet.
//
// Frontend sender intensjoner (ikke data). Maskinrommet validerer og
// skriver til PostgreSQL. PG NOTIFY-triggere sender sanntidsoppdateringer
// til klienter via WebSocket.
//
// Tilgangskontroll: Muterende operasjoner (update, delete) krever at
// brukeren er created_by på noden, eller har owner/admin-edge til den.
//
// Ref: docs/retninger/maskinrommet.md, docs/retninger/datalaget.md

use axum::{extract::{Multipart, State}, http::StatusCode, Json};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;

use crate::auth::{AdminUser, AuthUser};
use crate::livekit;
use crate::AppState;

/// Maks filstørrelse for upload: 100 MB.
const MAX_UPLOAD_SIZE: usize = 100 * 1024 * 1024;

// =============================================================================
// Felles
// =============================================================================

/// Gyldige visibility-verdier (speiler PG enum).
const VALID_VISIBILITIES: &[&str] = &["hidden", "discoverable", "readable", "open"];

/// Gyldige trait-navn for samlingsnoder.
/// Lukket katalog — ref: docs/primitiver/traits.md § "Trait-katalog"
const VALID_TRAITS: &[&str] = &[
    // Innhold & redigering
    "editor", "versioning", "collaboration", "translation", "templates",
    // Publisering & distribusjon
    "publishing", "rss", "newsletter", "custom_domain", "analytics", "embed", "api",
    // Lyd & video
    "podcast", "recording", "transcription", "tts", "clips", "playlist", "mixer", "studio",
    // Kommunikasjon
    "chat", "forum", "comments", "guest_input", "announcements", "polls", "qa",
    // Organisering
    "kanban", "calendar", "timeline", "table", "gallery", "bookmarks", "tags",
    // Kunnskap
    "knowledge_graph", "mindmap", "wiki", "glossary", "faq", "bibliography",
    // Automatisering & AI
    "auto_tag", "auto_summarize", "digest", "bridge", "moderation", "ai_tool", "orchestration",
    // Tilgang & fellesskap
    "membership", "roles", "invites", "paywall", "directory",
    // Ekstern integrasjon
    "webhook", "import", "export", "ical_sync",
];

/// Validerer `metadata.traits`-objektet for samlingsnoder.
///
/// Regler:
/// - Kun samlingsnoder (`node_kind == "collection"`) valideres.
/// - `traits` må være et objekt (ikke array, string, etc.).
/// - Hvert nøkkelnavn må finnes i VALID_TRAITS.
/// - Verdien per trait er fri JSONB (åpen konfigurasjon).
///
/// Ref: docs/primitiver/traits.md § "Lukket katalog, åpen konfigurasjon"
fn validate_collection_traits(
    node_kind: &str,
    metadata: &serde_json::Value,
) -> Result<(), String> {
    if node_kind != "collection" {
        return Ok(());
    }

    let traits = match metadata.get("traits") {
        None => return Ok(()), // Ingen traits er OK — samling uten funksjonalitet
        Some(t) => t,
    };

    let traits_obj = traits.as_object().ok_or(
        "metadata.traits må være et objekt".to_string(),
    )?;

    let unknown: Vec<&String> = traits_obj
        .keys()
        .filter(|k| !VALID_TRAITS.contains(&k.as_str()))
        .collect();

    if !unknown.is_empty() {
        let unknown_str: Vec<&str> = unknown.iter().map(|s| s.as_str()).collect();
        return Err(format!(
            "Ukjente traits: {:?}. Gyldige traits: se docs/primitiver/traits.md",
            unknown_str,
        ));
    }

    // Valider mindmap-konfigurasjon: dybde 1-3, layout radial/tree
    if let Some(mindmap) = traits_obj.get("mindmap") {
        if let Some(depth) = mindmap.get("default_depth") {
            if let Some(d) = depth.as_i64() {
                if !(1..=3).contains(&d) {
                    return Err("mindmap.default_depth må være 1, 2 eller 3".to_string());
                }
            }
        }
        if let Some(layout) = mindmap.get("layout").and_then(|v| v.as_str()) {
            if layout != "radial" && layout != "tree" {
                return Err(format!(
                    "mindmap.layout må være \"radial\" eller \"tree\", fikk \"{}\"",
                    layout
                ));
            }
        }
    }

    // Valider custom_domain DNS hvis satt i publishing-trait
    if let Some(publishing) = traits_obj.get("publishing") {
        if let Some(domain) = publishing.get("custom_domain").and_then(|v| v.as_str()) {
            if !domain.is_empty() {
                crate::custom_domain::validate_dns(domain)?;
            }
        }
    }

    Ok(())
}

/// Gyldige trigger-events for orchestration-noder.
/// Ref: docs/concepts/orkestrering.md § 5 "Kjente trigger-events"
const VALID_TRIGGER_EVENTS: &[&str] = &[
    "node.created",
    "edge.created",
    "communication.ended",
    "node.published",
    "scheduled.due",
    "manual",
];

/// Gyldige executor-verdier for orchestration-noder.
/// Ref: docs/concepts/orkestrering.md § 4 "Tre utførelsesnivåer"
const VALID_EXECUTORS: &[&str] = &["script", "bot", "dream"];

/// Gyldige modellprofiler for AI-presets.
/// Ref: docs/infra/ai_gateway.md § "Modellprofiler"
const VALID_MODEL_PROFILES: &[&str] = &["flash", "standard"];

/// Gyldige kategorier for AI-presets.
const VALID_AI_PRESET_CATEGORIES: &[&str] = &["standard", "custom"];

/// Gyldige retninger for AI-presets.
const VALID_AI_PRESET_DIRECTIONS: &[&str] = &["tool_to_node", "node_to_tool", "both"];

/// Validerer metadata for `node_kind == "ai_preset"`.
///
/// Påkrevde felter i metadata:
/// - `prompt` (string, ikke tom)
/// - `model_profile` (string, må finnes i VALID_MODEL_PROFILES)
/// - `category` (string, må finnes i VALID_AI_PRESET_CATEGORIES)
/// - `default_direction` (string, må finnes i VALID_AI_PRESET_DIRECTIONS)
/// - `icon` (string, ikke tom)
/// - `color` (string, hex-farge)
///
/// Ref: docs/features/ai_verktoy.md § 5
fn validate_ai_preset_metadata(
    node_kind: &str,
    metadata: &serde_json::Value,
) -> Result<(), String> {
    if node_kind != "ai_preset" {
        return Ok(());
    }

    // Påkrevd: prompt (ikke-tom streng)
    match metadata.get("prompt").and_then(|v| v.as_str()) {
        None | Some("") => return Err("ai_preset krever metadata.prompt (ikke-tom streng)".into()),
        _ => {}
    }

    // Påkrevd: model_profile
    match metadata.get("model_profile").and_then(|v| v.as_str()) {
        None => return Err("ai_preset krever metadata.model_profile".into()),
        Some(p) if !VALID_MODEL_PROFILES.contains(&p) => {
            return Err(format!(
                "Ugyldig model_profile: '{p}'. Gyldige verdier: {VALID_MODEL_PROFILES:?}"
            ));
        }
        _ => {}
    }

    // Påkrevd: category
    match metadata.get("category").and_then(|v| v.as_str()) {
        None => return Err("ai_preset krever metadata.category".into()),
        Some(c) if !VALID_AI_PRESET_CATEGORIES.contains(&c) => {
            return Err(format!(
                "Ugyldig category: '{c}'. Gyldige verdier: {VALID_AI_PRESET_CATEGORIES:?}"
            ));
        }
        _ => {}
    }

    // Påkrevd: default_direction
    match metadata.get("default_direction").and_then(|v| v.as_str()) {
        None => return Err("ai_preset krever metadata.default_direction".into()),
        Some(d) if !VALID_AI_PRESET_DIRECTIONS.contains(&d) => {
            return Err(format!(
                "Ugyldig default_direction: '{d}'. Gyldige verdier: {VALID_AI_PRESET_DIRECTIONS:?}"
            ));
        }
        _ => {}
    }

    // Påkrevd: icon (ikke-tom streng)
    match metadata.get("icon").and_then(|v| v.as_str()) {
        None | Some("") => return Err("ai_preset krever metadata.icon (ikke-tom streng)".into()),
        _ => {}
    }

    // Påkrevd: color (hex-farge)
    match metadata.get("color").and_then(|v| v.as_str()) {
        None | Some("") => return Err("ai_preset krever metadata.color (hex-farge)".into()),
        Some(c) if !c.starts_with('#') || c.len() != 7 => {
            return Err(format!("Ugyldig color: '{c}'. Forventet hex-farge (#RRGGBB)"));
        }
        _ => {}
    }

    Ok(())
}

/// Validerer metadata for `node_kind == "orchestration"`.
///
/// Påkrevde felter i metadata:
/// - `trigger` (objekt med `event` og valgfri `conditions`)
/// - `trigger.event` (string, må finnes i VALID_TRIGGER_EVENTS)
/// - `executor` (string, må finnes i VALID_EXECUTORS)
///
/// Valgfrie felter:
/// - `trigger.conditions` (objekt — fri filtrering)
/// - `intelligence` (heltall 1–3)
/// - `effort` (heltall 1–3)
/// - `compiled` (boolean — om scriptet er kompilert fra AI-modus)
/// - `pipeline` (array — sekvens av steg)
///
/// Ref: docs/concepts/orkestrering.md
fn validate_orchestration_metadata(
    node_kind: &str,
    metadata: &serde_json::Value,
) -> Result<(), String> {
    if node_kind != "orchestration" {
        return Ok(());
    }

    // Påkrevd: trigger (objekt)
    let trigger = metadata
        .get("trigger")
        .ok_or("orchestration krever metadata.trigger")?;
    let trigger_obj = trigger
        .as_object()
        .ok_or("metadata.trigger må være et objekt")?;

    // Påkrevd: trigger.event
    match trigger_obj.get("event").and_then(|v| v.as_str()) {
        None | Some("") => {
            return Err("orchestration krever metadata.trigger.event (ikke-tom streng)".into())
        }
        Some(ev) if !VALID_TRIGGER_EVENTS.contains(&ev) => {
            return Err(format!(
                "Ukjent trigger-event: '{ev}'. Gyldige verdier: {VALID_TRIGGER_EVENTS:?}"
            ));
        }
        _ => {}
    }

    // Valgfri: trigger.conditions (må være objekt hvis satt)
    if let Some(conditions) = trigger_obj.get("conditions") {
        if !conditions.is_object() {
            return Err("metadata.trigger.conditions må være et objekt".into());
        }
    }

    // Påkrevd: executor
    match metadata.get("executor").and_then(|v| v.as_str()) {
        None => return Err("orchestration krever metadata.executor".into()),
        Some(ex) if !VALID_EXECUTORS.contains(&ex) => {
            return Err(format!(
                "Ugyldig executor: '{ex}'. Gyldige verdier: {VALID_EXECUTORS:?}"
            ));
        }
        _ => {}
    }

    // Valgfri: intelligence (1–3)
    if let Some(val) = metadata.get("intelligence") {
        match val.as_i64() {
            Some(n) if (1..=3).contains(&n) => {}
            _ => return Err("metadata.intelligence må være et heltall 1–3".into()),
        }
    }

    // Valgfri: effort (1–3)
    if let Some(val) = metadata.get("effort") {
        match val.as_i64() {
            Some(n) if (1..=3).contains(&n) => {}
            _ => return Err("metadata.effort må være et heltall 1–3".into()),
        }
    }

    // Valgfri: compiled (boolean)
    if let Some(val) = metadata.get("compiled") {
        if !val.is_boolean() {
            return Err("metadata.compiled må være en boolean".into());
        }
    }

    // Valgfri: pipeline (array)
    if let Some(val) = metadata.get("pipeline") {
        if !val.is_array() {
            return Err("metadata.pipeline må være et array".into());
        }
    }

    Ok(())
}

/// Validerer metadata for oppgave-noder (proposal, assignment, task).
///
/// Ref: docs/infra/oppgaver.md
fn validate_task_metadata(
    node_kind: &str,
    metadata: &serde_json::Value,
) -> Result<(), String> {
    match node_kind {
        "proposal" => {
            // Valgfri status, default "draft"
            if let Some(status) = metadata.get("status").and_then(|v| v.as_str()) {
                let valid = ["draft", "discussed", "approved", "rejected", "parked"];
                if !valid.contains(&status) {
                    return Err(format!(
                        "proposal metadata.status må være en av: {}",
                        valid.join(", ")
                    ));
                }
            }
            Ok(())
        }
        "assignment" => {
            // Valgfri status, default "open"
            if let Some(status) = metadata.get("status").and_then(|v| v.as_str()) {
                let valid = ["open", "planning", "active", "paused", "done", "blocked"];
                if !valid.contains(&status) {
                    return Err(format!(
                        "assignment metadata.status må være en av: {}",
                        valid.join(", ")
                    ));
                }
            }
            // Valgfri priority (heltall)
            if let Some(val) = metadata.get("priority") {
                if !val.is_number() {
                    return Err("assignment metadata.priority må være et tall".into());
                }
            }
            Ok(())
        }
        "task" => {
            // Valgfri status, default "open"
            if let Some(status) = metadata.get("status").and_then(|v| v.as_str()) {
                let valid = ["open", "active", "done", "failed", "skipped"];
                if !valid.contains(&status) {
                    return Err(format!(
                        "task metadata.status må være en av: {}",
                        valid.join(", ")
                    ));
                }
            }
            // Valgfri priority (heltall)
            if let Some(val) = metadata.get("priority") {
                if !val.is_number() {
                    return Err("task metadata.priority må være et tall".into());
                }
            }
            Ok(())
        }
        _ => Ok(()),
    }
}

#[derive(Serialize)]
pub struct ErrorResponse {
    pub error: String,
}

fn bad_request(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
    (
        StatusCode::BAD_REQUEST,
        Json(ErrorResponse {
            error: msg.to_string(),
        }),
    )
}

fn forbidden(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
    (
        StatusCode::FORBIDDEN,
        Json(ErrorResponse {
            error: msg.to_string(),
        }),
    )
}

fn internal_error(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
    (
        StatusCode::INTERNAL_SERVER_ERROR,
        Json(ErrorResponse {
            error: msg.to_string(),
        }),
    )
}


// =============================================================================
// Tilgangskontroll og kontekstbasert identitet
// =============================================================================

/// Løser brukerens identitet i en kommunikasjonskontekst.
///
/// Hvis brukeren har et alias som er deltaker (owner, member_of, host_of)
/// i den gitte kommunikasjonsnoden, returneres alias-nodens ID.
/// Ellers returneres brukerens hoved-node_id.
///
/// Dette gjør at meldinger i en kommunikasjon automatisk krediteres
/// aliaset — f.eks. "Bjørn" i en podcast-samtale i stedet for "Vegard".
///
/// Ref: docs/primitiver/nodes.md (created_by), docs/primitiver/edges.md (alias)
async fn resolve_context_identity(
    db: &PgPool,
    user_id: Uuid,
    context_id: Uuid,
) -> Result<Uuid, sqlx::Error> {
    // Finn brukerens alias som er deltaker i kommunikasjonsnoden.
    // Alias-edge: user_id --alias(system=true)--> alias_id
    // Deltaker-edge: alias_id --owner/member_of/host_of--> context_id
    let alias_id = sqlx::query_scalar::<_, Uuid>(
        r#"
        SELECT e_alias.target_id
        FROM edges e_alias
        JOIN edges e_participant
            ON e_participant.source_id = e_alias.target_id
        WHERE e_alias.source_id = $1
          AND e_alias.edge_type = 'alias'
          AND e_alias.system = true
          AND e_participant.target_id = $2
          AND e_participant.edge_type IN ('owner', 'member_of', 'host_of')
        LIMIT 1
        "#,
    )
    .bind(user_id)
    .bind(context_id)
    .fetch_optional(db)
    .await?;

    Ok(alias_id.unwrap_or(user_id))
}

/// Henter alle alias-IDer for en bruker (via system alias-edges).
#[allow(dead_code)]
async fn user_alias_ids(db: &PgPool, user_id: Uuid) -> Result<Vec<Uuid>, sqlx::Error> {
    let ids = sqlx::query_scalar::<_, Uuid>(
        r#"
        SELECT target_id FROM edges
        WHERE source_id = $1 AND edge_type = 'alias' AND system = true
        "#,
    )
    .bind(user_id)
    .fetch_all(db)
    .await?;

    Ok(ids)
}

/// Sjekker om brukeren har skrivetilgang til en node.
/// Returnerer true hvis brukeren (eller et av brukerens aliaser) er created_by,
/// eller har owner/admin-edge til noden.
async fn user_can_modify_node(db: &PgPool, user_id: Uuid, node_id: Uuid) -> Result<bool, sqlx::Error> {
    // Sjekk direkte eierskap, alias-eierskap, eller admin/owner-edge
    let row = sqlx::query_scalar::<_, bool>(
        r#"
        SELECT EXISTS(
            SELECT 1 FROM nodes WHERE id = $1 AND created_by = $2
        ) OR EXISTS(
            SELECT 1 FROM edges
            WHERE source_id = $2 AND target_id = $1
              AND edge_type IN ('owner', 'admin')
        ) OR EXISTS(
            -- Sjekk om created_by er et av brukerens aliaser
            SELECT 1 FROM nodes n
            JOIN edges e_alias ON e_alias.target_id = n.created_by
            WHERE n.id = $1
              AND e_alias.source_id = $2
              AND e_alias.edge_type = 'alias'
              AND e_alias.system = true
        )
        "#,
    )
    .bind(node_id)
    .bind(user_id)
    .fetch_one(db)
    .await?;

    Ok(row)
}

/// Sjekker om brukeren har skrivetilgang til en edge.
/// Brukeren må ha opprettet edgen (direkte eller via alias),
/// eller ha owner/admin-edge til source-noden.
async fn user_can_modify_edge(db: &PgPool, user_id: Uuid, edge_id: Uuid) -> Result<bool, sqlx::Error> {
    let row = sqlx::query_scalar::<_, bool>(
        r#"
        SELECT EXISTS(
            SELECT 1 FROM edges WHERE id = $1 AND created_by = $2
        ) OR EXISTS(
            SELECT 1 FROM edges e
            JOIN edges access_edge ON access_edge.source_id = $2
                AND access_edge.target_id = e.source_id
                AND access_edge.edge_type IN ('owner', 'admin')
            WHERE e.id = $1
        ) OR EXISTS(
            -- Sjekk om created_by er et av brukerens aliaser
            SELECT 1 FROM edges e
            JOIN edges e_alias ON e_alias.target_id = e.created_by
            WHERE e.id = $1
              AND e_alias.source_id = $2
              AND e_alias.edge_type = 'alias'
              AND e_alias.system = true
        )
        "#,
    )
    .bind(edge_id)
    .bind(user_id)
    .fetch_one(db)
    .await?;

    Ok(row)
}

/// Sjekker om en node eksisterer i PG.
async fn node_exists(db: &PgPool, node_id: Uuid) -> Result<bool, sqlx::Error> {
    sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1)")
        .bind(node_id)
        .fetch_one(db)
        .await
}

/// Henter publishing-trait-konfig for en node (hvis den er en samling med publishing-trait).
/// Wrapper rundt publishing::find_publishing_collection_by_id med feil-mapping for handlers.
async fn get_publishing_config(
    db: &PgPool,
    node_id: Uuid,
) -> Result<Option<crate::publishing::PublishingConfig>, (StatusCode, Json<ErrorResponse>)> {
    crate::publishing::find_publishing_collection_by_id(db, node_id)
        .await
        .map_err(|e| {
            tracing::error!("PG-feil ved publishing-config-oppslag: {e}");
            internal_error("Databasefeil ved publiseringssjekk")
        })
}

/// Returnerer brukerens høyeste rolle-edge til en node.
/// Sjekker owner, admin, member_of, reader-edges.
/// Returnerer "owner", "admin", "member", "reader", eller None.
async fn get_user_role_for_node(
    db: &PgPool,
    user_id: Uuid,
    node_id: Uuid,
) -> Result<Option<String>, (StatusCode, Json<ErrorResponse>)> {
    // Sjekk i prioritetsrekkefølge: owner > admin > member > reader
    let role: Option<String> = sqlx::query_scalar(
        r#"
        SELECT CASE
            WHEN EXISTS(SELECT 1 FROM edges WHERE source_id = $1 AND target_id = $2 AND edge_type = 'owner') THEN 'owner'
            WHEN EXISTS(SELECT 1 FROM edges WHERE source_id = $1 AND target_id = $2 AND edge_type = 'admin') THEN 'admin'
            WHEN EXISTS(SELECT 1 FROM edges WHERE source_id = $1 AND target_id = $2 AND edge_type = 'member_of') THEN 'member'
            WHEN EXISTS(SELECT 1 FROM edges WHERE source_id = $1 AND target_id = $2 AND edge_type = 'reader') THEN 'reader'
            ELSE NULL
        END
        "#,
    )
    .bind(user_id)
    .bind(node_id)
    .fetch_one(db)
    .await
    .map_err(|e| {
        tracing::error!("PG-feil ved rolle-oppslag: {e}");
        internal_error("Databasefeil ved rollesjekk")
    })?;

    Ok(role)
}

/// Sjekker om brukeren er owner eller admin av en node.
async fn user_is_owner_or_admin(
    db: &PgPool,
    user_id: Uuid,
    node_id: Uuid,
) -> Result<bool, (StatusCode, Json<ErrorResponse>)> {
    sqlx::query_scalar::<_, bool>(
        r#"
        SELECT EXISTS(
            SELECT 1 FROM edges
            WHERE source_id = $1 AND target_id = $2
              AND edge_type IN ('owner', 'admin')
        )
        "#,
    )
    .bind(user_id)
    .bind(node_id)
    .fetch_one(db)
    .await
    .map_err(|e| {
        tracing::error!("PG-feil ved owner/admin-sjekk: {e}");
        internal_error("Databasefeil ved tilgangssjekk")
    })
}

// =============================================================================
// create_node
// =============================================================================

#[derive(Deserialize)]
pub struct CreateNodeRequest {
    /// Hint om hva noden er. Default: "content".
    pub node_kind: Option<String>,
    /// Visningstittel. Kan være null (f.eks. chatmeldinger).
    pub title: Option<String>,
    /// Ren tekst-innhold.
    pub content: Option<String>,
    /// Synlighet. Default: "hidden" (privat).
    pub visibility: Option<String>,
    /// Typespesifikk metadata (JSON-objekt).
    pub metadata: Option<serde_json::Value>,
    /// Kontekst-node (f.eks. kommunikasjonsnode). Hvis satt, opprettes
    /// automatisk en `belongs_to`-edge fra den nye noden til kontekstnoden.
    /// Ref: docs/retninger/universell_input.md (kontekst-arv).
    pub context_id: Option<Uuid>,
}

#[derive(Serialize)]
pub struct CreateNodeResponse {
    pub node_id: Uuid,
    /// Edge-ID for automatisk opprettet `belongs_to`-edge (kun ved context_id).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub belongs_to_edge_id: Option<Uuid>,
}

/// POST /intentions/create_node
///
/// Validerer input, skriver til PG. NOTIFY-triggere sender sanntidsoppdateringer.
///
/// Hvis `context_id` er satt, opprettes automatisk en `belongs_to`-edge
/// fra den nye noden til kontekstnoden. Kontekstnoden må eksistere og
/// være en kommunikasjonsnode. Ref: docs/retninger/universell_input.md
pub async fn create_node(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<CreateNodeRequest>,
) -> Result<Json<CreateNodeResponse>, (StatusCode, Json<ErrorResponse>)> {
    // -- Valider input --
    let node_kind = req.node_kind.unwrap_or_else(|| "content".to_string());
    if node_kind.is_empty() {
        return Err(bad_request("node_kind kan ikke være tom"));
    }

    let visibility = req.visibility.unwrap_or_else(|| "hidden".to_string());
    if !VALID_VISIBILITIES.contains(&visibility.as_str()) {
        return Err(bad_request(&format!(
            "Ugyldig visibility: '{visibility}'. Gyldige verdier: {VALID_VISIBILITIES:?}"
        )));
    }

    // -- Valider context_id hvis satt --
    if let Some(ctx_id) = req.context_id {
        let ctx_node = sqlx::query_as::<_, NodeKindRow>(
            "SELECT node_kind FROM nodes WHERE id = $1",
        )
        .bind(ctx_id)
        .fetch_optional(&state.db)
        .await
        .map_err(|e| {
            tracing::error!("PG-feil ved context_id-sjekk: {e}");
            internal_error("Databasefeil ved validering av context_id")
        })?;

        match ctx_node {
            None => return Err(bad_request(&format!("context_id {} finnes ikke", ctx_id))),
            Some(row) if row.node_kind != "communication" => {
                return Err(bad_request(&format!(
                    "context_id {} er en '{}'-node, ikke en kommunikasjonsnode",
                    ctx_id, row.node_kind
                )));
            }
            _ => {} // OK — kommunikasjonsnode
        }
    }

    let title = req.title.unwrap_or_default();
    let content = req.content.unwrap_or_default();
    let metadata = req
        .metadata
        .unwrap_or_else(|| serde_json::json!({}));

    // -- Valider traits for samlingsnoder (oppgave 13.1) --
    validate_collection_traits(&node_kind, &metadata).map_err(|e| bad_request(&e))?;

    // -- Valider metadata for AI-presets (oppgave 18.1) --
    validate_ai_preset_metadata(&node_kind, &metadata).map_err(|e| bad_request(&e))?;

    // -- Valider metadata for orchestration-noder (oppgave 24.1) --
    validate_orchestration_metadata(&node_kind, &metadata).map_err(|e| bad_request(&e))?;

    // -- Valider metadata for oppgave-noder (proposal/assignment/task) --
    validate_task_metadata(&node_kind, &metadata).map_err(|e| bad_request(&e))?;

    // -- Kontekstbasert identitet (oppgave 8.2) --
    // Hvis context_id er satt, sjekk om brukeren har et alias som er
    // deltaker i kommunikasjonsnoden. I så fall brukes aliaset som created_by.
    let effective_identity = if let Some(ctx_id) = req.context_id {
        resolve_context_identity(&state.db, user.node_id, ctx_id)
            .await
            .map_err(|e| {
                tracing::error!("PG-feil ved identitetsoppslag: {e}");
                internal_error("Databasefeil ved identitetsoppslag")
            })?
    } else {
        user.node_id
    };

    // -- Generer UUIDv7 (tidssortert) --
    let node_id = Uuid::now_v7();

    // Fang verdier for AI-trigger før de brukes i PG-insert
    let is_content_node = node_kind == "content";
    let has_enough_text = content.len() >= 20 || title.len() >= 20;

    // -- Skriv til PostgreSQL (NOTIFY-trigger sender sanntidsoppdatering) --
    sqlx::query(
        r#"INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
           VALUES ($1, $2, NULLIF($3, ''), NULLIF($4, ''), $5::visibility, $6, $7)"#,
    )
    .bind(node_id)
    .bind(&node_kind)
    .bind(&title)
    .bind(&content)
    .bind(&visibility)
    .bind(&metadata)
    .bind(effective_identity)
    .execute(&state.db)
    .await
    .map_err(|e| {
        tracing::error!("PG-feil ved opprettelse av node: {e}");
        internal_error("Databasefeil ved opprettelse av node")
    })?;

    tracing::info!(
        node_id = %node_id,
        node_kind = %node_kind,
        created_by = %effective_identity,
        auth_user = %user.node_id,
        context_id = ?req.context_id,
        alias_used = %(effective_identity != user.node_id),
        "Node opprettet"
    );

    // -- Kontekst-arv: automatisk belongs_to-edge --
    let belongs_to_edge_id = if let Some(ctx_id) = req.context_id {
        let edge_id = Uuid::now_v7();
        let bt_metadata = serde_json::json!({});

        sqlx::query(
            r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
               VALUES ($1, $2, $3, 'belongs_to', $4, false, $5)"#,
        )
        .bind(edge_id)
        .bind(node_id)
        .bind(ctx_id)
        .bind(&bt_metadata)
        .bind(effective_identity)
        .execute(&state.db)
        .await
        .map_err(|e| {
            tracing::error!("PG-feil ved opprettelse av belongs_to-edge: {e}");
            internal_error("Databasefeil ved opprettelse av edge")
        })?;

        tracing::info!(
            edge_id = %edge_id,
            node_id = %node_id,
            context_id = %ctx_id,
            "belongs_to-edge opprettet (kontekst-arv)"
        );

        // Propager tilgang: alle som har tilgang til ctx_id får tilgang til node_id
        if let Err(e) = sqlx::query("SELECT propagate_belongs_to_access($1, $2)")
            .bind(node_id)
            .bind(ctx_id)
            .execute(&state.db)
            .await
        {
            tracing::error!("propagate_belongs_to_access feilet: {e}");
        }

        Some(edge_id)
    } else {
        None
    };

    // -- Agent-trigger: sjekk om kommunikasjonsnoden har en agent-deltaker --
    if let Some(ctx_id) = req.context_id {
        let db_clone = state.db.clone();
        let user_node_id = user.node_id;
        let created_node_id = node_id;
        tokio::spawn(async move {
            match crate::agent::find_agent_participant(&db_clone, ctx_id).await {
                Ok(Some(agent_id)) if agent_id != effective_identity => {
                    // Agent funnet, og melding er ikke fra agenten selv
                    let payload = serde_json::json!({
                        "communication_id": ctx_id.to_string(),
                        "message_id": created_node_id.to_string(),
                        "agent_node_id": agent_id.to_string(),
                        "sender_node_id": user_node_id.to_string()
                    });
                    match crate::jobs::enqueue(&db_clone, "agent_respond", payload, None, 8).await {
                        Ok(job_id) => {
                            tracing::info!(
                                job_id = %job_id,
                                communication_id = %ctx_id,
                                agent_node_id = %agent_id,
                                "agent_respond-jobb lagt i kø"
                            );
                        }
                        Err(e) => {
                            tracing::error!(
                                communication_id = %ctx_id,
                                error = %e,
                                "Kunne ikke legge agent_respond-jobb i kø"
                            );
                        }
                    }
                }
                Ok(_) => {} // Ingen agent, eller melding fra agenten selv
                Err(e) => {
                    tracing::error!(
                        communication_id = %ctx_id,
                        error = %e,
                        "Feil ved agent-deltaker-sjekk"
                    );
                }
            }
        });
    }

    // -- AI edge-forslag: analyser innholdet for topics og mentions --
    // Trigges for content-noder med nok tekst. Lav prioritet (bakgrunnsjobb).
    // NB: node_kind, title og content er flyttet inn i enqueue_insert_node over,
    // så vi sjekker på kopi av verdiene tatt før move.
    if is_content_node && has_enough_text {
        let db_clone = state.db.clone();
        let created_node_id = node_id;
        tokio::spawn(async move {
            let payload = serde_json::json!({
                "node_id": created_node_id.to_string()
            });
            match crate::jobs::enqueue(&db_clone, "suggest_edges", payload, None, 2).await {
                Ok(job_id) => {
                    tracing::info!(
                        job_id = %job_id,
                        node_id = %created_node_id,
                        "suggest_edges-jobb lagt i kø"
                    );
                }
                Err(e) => {
                    tracing::error!(
                        node_id = %created_node_id,
                        error = %e,
                        "Kunne ikke legge suggest_edges-jobb i kø"
                    );
                }
            }
        });
    }

    Ok(Json(CreateNodeResponse { node_id, belongs_to_edge_id }))
}

// =============================================================================
// create_edge
// =============================================================================

#[derive(Deserialize)]
pub struct CreateEdgeRequest {
    /// Kilde-node (fra).
    pub source_id: Uuid,
    /// Mål-node (til).
    pub target_id: Uuid,
    /// Relasjontype (freeform streng). Ref: docs/primitiver/edges.md
    pub edge_type: String,
    /// Typespesifikk metadata (JSON-objekt).
    pub metadata: Option<serde_json::Value>,
    /// Systemedge — usynlig ved traversering. Default: false.
    pub system: Option<bool>,
}

#[derive(Serialize)]
pub struct CreateEdgeResponse {
    pub edge_id: Uuid,
}

/// POST /intentions/create_edge
///
/// Oppretter en retningsbestemt edge mellom to noder.
/// Krever at begge nodene eksisterer. Brukeren trenger ikke spesiell
/// tilgang for å opprette edges — tilgangskontroll på edges håndheves
/// ved lesing (node_access-matrisen, fase 4).
pub async fn create_edge(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<CreateEdgeRequest>,
) -> Result<Json<CreateEdgeResponse>, (StatusCode, Json<ErrorResponse>)> {
    // -- Valider input --
    if req.edge_type.is_empty() {
        return Err(bad_request("edge_type kan ikke være tom"));
    }

    // Sjekk at begge nodene eksisterer
    let (source_exists, target_exists) = tokio::try_join!(
        node_exists(&state.db, req.source_id),
        node_exists(&state.db, req.target_id),
    )
    .map_err(|e| {
        tracing::error!("PG-feil ved nodesjekk: {e}");
        internal_error("Databasefeil ved validering")
    })?;

    if !source_exists {
        return Err(bad_request(&format!("source_id {} finnes ikke", req.source_id)));
    }
    if !target_exists {
        return Err(bad_request(&format!("target_id {} finnes ikke", req.target_id)));
    }

    let mut metadata = req.metadata.unwrap_or_else(|| serde_json::json!({}));
    let system = req.system.unwrap_or(false);

    // -- Publiseringsvalidering for submitted_to og belongs_to --
    if req.edge_type == "submitted_to" || req.edge_type == "belongs_to" {
        if let Some(config) = get_publishing_config(&state.db, req.target_id).await? {
            if config.require_approval {
                if req.edge_type == "submitted_to" {
                    // Kun roller i submission_roles (eller owner/admin) kan opprette submitted_to
                    let user_role = get_user_role_for_node(&state.db, user.node_id, req.target_id).await?;
                    let allowed = match &user_role {
                        Some(role) if role == "owner" || role == "admin" => true,
                        Some(role) => config.submission_roles.contains(role),
                        None => false,
                    };
                    if !allowed {
                        return Err(forbidden(
                            "Du har ikke riktig rolle til å sende inn til denne samlingen",
                        ));
                    }
                    // Sett status og submitted_at automatisk
                    if let Some(obj) = metadata.as_object_mut() {
                        obj.insert("status".to_string(), serde_json::json!("pending"));
                        obj.insert(
                            "submitted_at".to_string(),
                            serde_json::json!(chrono::Utc::now().to_rfc3339()),
                        );
                    }
                } else {
                    // belongs_to til require_approval-samling: kun owner/admin
                    let is_owner_admin =
                        user_is_owner_or_admin(&state.db, user.node_id, req.target_id).await?;
                    if !is_owner_admin {
                        return Err(forbidden(
                            "Kun owner/admin kan publisere direkte til denne samlingen (require_approval er aktiv)",
                        ));
                    }
                }
            }
        }
    }

    // -- Validering av source_material-metadata --
    if req.edge_type == "source_material" {
        if let Some(obj) = metadata.as_object() {
            // context er påkrevd og må være en av tre gyldige verdier
            match obj.get("context").and_then(|v| v.as_str()) {
                Some("quoted" | "summarized" | "referenced") => {}
                Some(other) => {
                    return Err(bad_request(&format!(
                        "source_material context må være 'quoted', 'summarized' eller 'referenced', fikk '{other}'"
                    )));
                }
                None => {
                    return Err(bad_request(
                        "source_material krever 'context'-felt (quoted, summarized eller referenced)",
                    ));
                }
            }
            // excerpt er påkrevd og må være en ikke-tom streng
            match obj.get("excerpt").and_then(|v| v.as_str()) {
                Some(s) if !s.trim().is_empty() => {}
                _ => {
                    return Err(bad_request(
                        "source_material krever et ikke-tomt 'excerpt'-felt",
                    ));
                }
            }
        } else {
            return Err(bad_request(
                "source_material krever metadata med 'context' og 'excerpt'",
            ));
        }
    }

    // -- Generer UUIDv7 --
    let edge_id = Uuid::now_v7();

    // -- Skriv til PostgreSQL (NOTIFY-trigger sender sanntidsoppdatering) --
    let access_level = match req.edge_type.as_str() {
        "owner" => Some("owner"),
        "admin" => Some("admin"),
        "member_of" => Some("member"),
        "reader" => Some("reader"),
        _ => None,
    };

    if let Some(level) = access_level {
        // Tilgangsgivende edge: transaksjon med recompute_access
        let mut tx = state.db.begin().await.map_err(|e| {
            tracing::error!("PG begin: {e}");
            internal_error("Databasefeil")
        })?;

        sqlx::query(
            r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
               VALUES ($1, $2, $3, $4, $5, $6, $7)"#,
        )
        .bind(edge_id)
        .bind(req.source_id)
        .bind(req.target_id)
        .bind(&req.edge_type)
        .bind(&metadata)
        .bind(system)
        .bind(user.node_id)
        .execute(&mut *tx)
        .await
        .map_err(|e| {
            tracing::error!("PG insert edge: {e}");
            internal_error("Databasefeil ved opprettelse av edge")
        })?;

        sqlx::query("SELECT recompute_access($1, $2, $3::access_level, $4)")
            .bind(req.source_id)
            .bind(req.target_id)
            .bind(level)
            .bind(edge_id)
            .execute(&mut *tx)
            .await
            .map_err(|e| {
                tracing::error!("recompute_access: {e}");
                internal_error("Databasefeil ved tilgangsberegning")
            })?;

        tx.commit().await.map_err(|e| {
            tracing::error!("PG commit: {e}");
            internal_error("Databasefeil")
        })?;
    } else {
        sqlx::query(
            r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
               VALUES ($1, $2, $3, $4, $5, $6, $7)"#,
        )
        .bind(edge_id)
        .bind(req.source_id)
        .bind(req.target_id)
        .bind(&req.edge_type)
        .bind(&metadata)
        .bind(system)
        .bind(user.node_id)
        .execute(&state.db)
        .await
        .map_err(|e| {
            tracing::error!("PG insert edge: {e}");
            internal_error("Databasefeil ved opprettelse av edge")
        })?;

        // Propager tilgang ved belongs_to: alle som har tilgang til target (forelder) får tilgang til source (barn)
        if req.edge_type == "belongs_to" {
            if let Err(e) = sqlx::query("SELECT propagate_belongs_to_access($1, $2)")
                .bind(req.source_id)
                .bind(req.target_id)
                .execute(&state.db)
                .await
            {
                tracing::error!("propagate_belongs_to_access feilet: {e}");
            }
        }

        // Trigger rendering ved belongs_to
        if req.edge_type == "belongs_to" {
            if let Ok(Some(config)) = crate::publishing::find_publishing_collection_by_id(&state.db, req.target_id).await {
                let article_payload = serde_json::json!({
                    "node_id": req.source_id.to_string(),
                    "collection_id": req.target_id.to_string(),
                });
                let _ = crate::jobs::enqueue(&state.db, "render_article", article_payload, Some(req.target_id), 5).await;

                let index_mode = config.index_mode.as_deref().unwrap_or("dynamic");
                if index_mode == "static" {
                    let index_payload = serde_json::json!({ "collection_id": req.target_id.to_string() });
                    let _ = crate::jobs::enqueue(&state.db, "render_index", index_payload, Some(req.target_id), 4).await;
                } else {
                    crate::publishing::invalidate_index_cache(&state.index_cache, req.target_id).await;
                }
            }
        }

        // A/B-test for presentasjonselement-edges
        if matches!(req.edge_type.as_str(), "title" | "subtitle" | "summary" | "og_image") {
            crate::publishing::maybe_start_ab_test(&state.db, req.target_id, &req.edge_type).await;
        }
    }

    tracing::info!(
        edge_id = %edge_id,
        source_id = %req.source_id,
        target_id = %req.target_id,
        edge_type = %req.edge_type,
        created_by = %user.node_id,
        "Edge opprettet"
    );

    Ok(Json(CreateEdgeResponse { edge_id }))
}

// =============================================================================
// update_node
// =============================================================================

#[derive(Deserialize)]
pub struct UpdateNodeRequest {
    /// ID til noden som skal oppdateres.
    pub node_id: Uuid,
    /// Ny node_kind. Beholder eksisterende hvis None.
    pub node_kind: Option<String>,
    /// Ny tittel. Beholder eksisterende hvis None.
    pub title: Option<String>,
    /// Nytt innhold. Beholder eksisterende hvis None.
    pub content: Option<String>,
    /// Ny synlighet. Beholder eksisterende hvis None.
    pub visibility: Option<String>,
    /// Ny metadata. Beholder eksisterende hvis None.
    pub metadata: Option<serde_json::Value>,
}

#[derive(Serialize)]
pub struct UpdateNodeResponse {
    pub node_id: Uuid,
}

/// POST /intentions/update_node
///
/// Oppdaterer en eksisterende node. Krever at brukeren er created_by
/// eller har owner/admin-edge til noden.
pub async fn update_node(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<UpdateNodeRequest>,
) -> Result<Json<UpdateNodeResponse>, (StatusCode, Json<ErrorResponse>)> {
    // -- Tilgangskontroll --
    let can_modify = user_can_modify_node(&state.db, user.node_id, req.node_id)
        .await
        .map_err(|e| {
            tracing::error!("PG-feil ved tilgangssjekk: {e}");
            internal_error("Databasefeil ved tilgangssjekk")
        })?;

    if !can_modify {
        return Err(forbidden("Ingen tilgang til å endre denne noden"));
    }

    // -- Hent eksisterende node fra PG for å fylle inn manglende felt --
    let existing = sqlx::query_as::<_, NodeRow>(
        "SELECT node_kind, title, content, visibility::text, metadata FROM nodes WHERE id = $1",
    )
    .bind(req.node_id)
    .fetch_optional(&state.db)
    .await
    .map_err(|e| {
        tracing::error!("PG-feil ved henting av node: {e}");
        internal_error("Databasefeil ved henting av node")
    })?
    .ok_or_else(|| bad_request(&format!("Node {} finnes ikke", req.node_id)))?;

    let node_kind = req.node_kind.unwrap_or(existing.node_kind);
    if node_kind.is_empty() {
        return Err(bad_request("node_kind kan ikke være tom"));
    }

    let visibility = req.visibility.unwrap_or(existing.visibility);
    if !VALID_VISIBILITIES.contains(&visibility.as_str()) {
        return Err(bad_request(&format!(
            "Ugyldig visibility: '{visibility}'. Gyldige verdier: {VALID_VISIBILITIES:?}"
        )));
    }

    let title = req.title.unwrap_or(existing.title.unwrap_or_default());
    let content = req.content.unwrap_or(existing.content.unwrap_or_default());

    // Hent gamle publishing-verdier før existing.metadata flyttes
    let old_publishing = existing.metadata
        .get("traits")
        .and_then(|t| t.get("publishing"));

    let old_domain = old_publishing
        .and_then(|p| p.get("custom_domain"))
        .and_then(|d| d.as_str())
        .unwrap_or("")
        .to_string();

    let old_theme = old_publishing
        .and_then(|p| p.get("theme"))
        .cloned();

    let old_theme_config = old_publishing
        .and_then(|p| p.get("theme_config"))
        .cloned();

    // Hent gamle AI-preset-verdier før existing.metadata flyttes (oppgave 18.6)
    let old_model_profile = existing.metadata
        .get("model_profile")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string());
    let old_category = existing.metadata
        .get("category")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string());

    let metadata = req.metadata.unwrap_or(existing.metadata);

    // -- Valider traits for samlingsnoder (oppgave 13.1) --
    validate_collection_traits(&node_kind, &metadata).map_err(|e| bad_request(&e))?;

    // -- Valider metadata for AI-presets (oppgave 18.1) --
    validate_ai_preset_metadata(&node_kind, &metadata).map_err(|e| bad_request(&e))?;

    // -- Valider metadata for orchestration-noder (oppgave 24.1) --
    validate_orchestration_metadata(&node_kind, &metadata).map_err(|e| bad_request(&e))?;

    // -- Valider metadata for oppgave-noder --
    validate_task_metadata(&node_kind, &metadata).map_err(|e| bad_request(&e))?;

    // -- Beskytt model_profile for egendefinerte AI-presets (oppgave 18.6) --
    // Kun admin/owner på en samling kan endre model_profile. Vanlige brukere
    // som eier et custom preset kan redigere alt annet (prompt, icon, farge osv.)
    if node_kind == "ai_preset" {
        let new_profile = metadata.get("model_profile").and_then(|v| v.as_str()).map(|s| s.to_string());

        if new_profile != old_model_profile {
            // Sjekk om brukeren er admin (har owner-edge til en samling)
            let is_admin_user = sqlx::query_scalar::<_, bool>(
                r#"
                SELECT EXISTS(
                    SELECT 1 FROM edges
                    WHERE source_id = $1
                      AND edge_type IN ('owner', 'admin')
                      AND target_id IN (SELECT id FROM nodes WHERE node_kind = 'collection')
                )
                "#,
            )
            .bind(user.node_id)
            .fetch_one(&state.db)
            .await
            .map_err(|e| {
                tracing::error!(error = %e, "PG-feil ved admin-sjekk for model_profile");
                internal_error("Databasefeil")
            })?;

            if !is_admin_user {
                return Err(forbidden(
                    "Kun admin kan endre modellprofil for AI-presets",
                ));
            }
        }

        // Forhindre at vanlige brukere endrer category fra custom til standard
        let new_cat = metadata.get("category").and_then(|v| v.as_str()).map(|s| s.to_string());
        if new_cat != old_category && new_cat.as_deref() == Some("standard") {
            return Err(forbidden(
                "Kan ikke endre kategori til 'standard' — det er reservert for systempresets",
            ));
        }
    }

    // -- Sjekk om custom_domain er endret (for re-rendering) --
    let new_domain = metadata
        .get("traits")
        .and_then(|t| t.get("publishing"))
        .and_then(|p| p.get("custom_domain"))
        .and_then(|d| d.as_str())
        .unwrap_or("");
    let domain_changed = old_domain != new_domain && node_kind == "collection";

    // -- Sjekk om theme eller theme_config er endret (for bulk re-rendering, oppgave 14.14) --
    let theme_changed = if node_kind == "collection" {
        let new_publishing = metadata
            .get("traits")
            .and_then(|t| t.get("publishing"));

        let new_theme = new_publishing.and_then(|p| p.get("theme")).cloned();
        let new_theme_config = new_publishing.and_then(|p| p.get("theme_config")).cloned();

        old_theme != new_theme || old_theme_config != new_theme_config
    } else {
        false
    };

    // -- Skriv til PostgreSQL (NOTIFY-trigger sender sanntidsoppdatering) --
    sqlx::query(
        r#"UPDATE nodes SET node_kind = $2, title = NULLIF($3, ''), content = NULLIF($4, ''),
           visibility = $5::visibility, metadata = $6 WHERE id = $1"#,
    )
    .bind(req.node_id)
    .bind(&node_kind)
    .bind(&title)
    .bind(&content)
    .bind(&visibility)
    .bind(&metadata)
    .execute(&state.db)
    .await
    .map_err(|e| {
        tracing::error!("PG-feil ved oppdatering av node: {e}");
        internal_error("Databasefeil ved oppdatering av node")
    })?;

    tracing::info!(
        node_id = %req.node_id,
        updated_by = %user.node_id,
        "Node oppdatert"
    );

    // -- Re-render alle artikler hvis custom_domain endret (canonical URL) --
    if domain_changed {
        let db = state.db.clone();
        let collection_id = req.node_id;
        tokio::spawn(async move {
            match crate::custom_domain::rerender_collection_articles(&db, collection_id).await {
                Ok(count) => tracing::info!(
                    collection_id = %collection_id,
                    articles = count,
                    "Re-rendering trigget etter domeneendring"
                ),
                Err(e) => tracing::error!(
                    collection_id = %collection_id,
                    error = %e,
                    "Feil ved re-rendering etter domeneendring"
                ),
            }
        });
    }

    // -- Bulk re-rendering hvis theme/theme_config endret (oppgave 14.14) --
    if theme_changed {
        let db = state.db.clone();
        let collection_id = req.node_id;
        tokio::spawn(async move {
            match crate::publishing::trigger_bulk_rerender(&db, collection_id).await {
                Ok(count) => tracing::info!(
                    collection_id = %collection_id,
                    articles = count,
                    "Bulk re-rendering trigget etter temaendring"
                ),
                Err(e) => tracing::error!(
                    collection_id = %collection_id,
                    error = %e,
                    "Feil ved bulk re-rendering etter temaendring"
                ),
            }
        });
    }

    Ok(Json(UpdateNodeResponse { node_id: req.node_id }))
}

// =============================================================================
// delete_node
// =============================================================================

#[derive(Deserialize)]
pub struct DeleteNodeRequest {
    /// ID til noden som skal slettes.
    pub node_id: Uuid,
}

#[derive(Serialize)]
pub struct DeleteNodeResponse {
    pub deleted: bool,
}

/// POST /intentions/delete_node
///
/// Sletter en node og alle dens edges (CASCADE i PG).
/// Krever at brukeren er created_by eller har owner/admin-edge til noden.
pub async fn delete_node(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<DeleteNodeRequest>,
) -> Result<Json<DeleteNodeResponse>, (StatusCode, Json<ErrorResponse>)> {
    // -- Tilgangskontroll --
    let can_modify = user_can_modify_node(&state.db, user.node_id, req.node_id)
        .await
        .map_err(|e| {
            tracing::error!("PG-feil ved tilgangssjekk: {e}");
            internal_error("Databasefeil ved tilgangssjekk")
        })?;

    if !can_modify {
        return Err(forbidden("Ingen tilgang til å slette denne noden"));
    }

    // Sjekk at noden eksisterer
    let exists = node_exists(&state.db, req.node_id)
        .await
        .map_err(|e| {
            tracing::error!("PG-feil ved nodesjekk: {e}");
            internal_error("Databasefeil ved validering")
        })?;

    if !exists {
        return Err(bad_request(&format!("Node {} finnes ikke", req.node_id)));
    }

    // -- Slett fra PostgreSQL (NOTIFY-trigger sender sanntidsoppdatering) --
    sqlx::query("DELETE FROM nodes WHERE id = $1")
        .bind(req.node_id)
        .execute(&state.db)
        .await
        .map_err(|e| {
            tracing::error!("PG-feil ved sletting av node: {e}");
            internal_error("Databasefeil ved sletting av node")
        })?;

    tracing::info!(
        node_id = %req.node_id,
        deleted_by = %user.node_id,
        "Node slettet"
    );

    Ok(Json(DeleteNodeResponse { deleted: true }))
}

// =============================================================================
// update_edge
// =============================================================================

#[derive(Deserialize)]
pub struct UpdateEdgeRequest {
    /// ID til edgen som skal oppdateres.
    pub edge_id: Uuid,
    /// Ny edge_type. Beholder eksisterende hvis None.
    pub edge_type: Option<String>,
    /// Ny metadata. Beholder eksisterende hvis None.
    pub metadata: Option<serde_json::Value>,
}

#[derive(Serialize)]
pub struct UpdateEdgeResponse {
    pub edge_id: Uuid,
}

/// Henter en edge fra PG.
async fn get_edge(db: &PgPool, edge_id: Uuid) -> Result<Option<EdgeRow>, sqlx::Error> {
    sqlx::query_as::<_, EdgeRow>(
        "SELECT source_id, target_id, edge_type, metadata FROM edges WHERE id = $1",
    )
    .bind(edge_id)
    .fetch_optional(db)
    .await
}

#[derive(sqlx::FromRow)]
#[allow(dead_code)]
struct EdgeRow {
    source_id: Uuid,
    target_id: Uuid,
    edge_type: String,
    metadata: serde_json::Value,
}

/// POST /intentions/update_edge
///
/// Oppdaterer en eksisterende edge (type og/eller metadata).
/// Krever at brukeren har opprettet edgen, eller har owner/admin-edge
/// til source-noden.
pub async fn update_edge(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<UpdateEdgeRequest>,
) -> Result<Json<UpdateEdgeResponse>, (StatusCode, Json<ErrorResponse>)> {
    // -- Tilgangskontroll --
    let can_modify = user_can_modify_edge(&state.db, user.node_id, req.edge_id)
        .await
        .map_err(|e| {
            tracing::error!("PG-feil ved tilgangssjekk: {e}");
            internal_error("Databasefeil ved tilgangssjekk")
        })?;

    if !can_modify {
        return Err(forbidden("Ingen tilgang til å endre denne edgen"));
    }

    // -- Hent eksisterende edge --
    let existing = get_edge(&state.db, req.edge_id)
        .await
        .map_err(|e| {
            tracing::error!("PG-feil ved henting av edge: {e}");
            internal_error("Databasefeil ved henting av edge")
        })?
        .ok_or_else(|| bad_request(&format!("Edge {} finnes ikke", req.edge_id)))?;

    let edge_type = req.edge_type.unwrap_or(existing.edge_type.clone());
    if edge_type.is_empty() {
        return Err(bad_request("edge_type kan ikke være tom"));
    }

    let metadata = req.metadata.unwrap_or(existing.metadata.clone());

    // -- submitted_to status-endring: kun owner/admin av samlingen --
    if existing.edge_type == "submitted_to" {
        let new_status = metadata.get("status").and_then(|v| v.as_str());
        let old_status = existing.metadata.get("status").and_then(|v| v.as_str());
        if new_status != old_status {
            let is_owner_admin =
                user_is_owner_or_admin(&state.db, user.node_id, existing.target_id).await?;
            if !is_owner_admin {
                return Err(forbidden(
                    "Kun owner/admin av samlingen kan endre status på innsendte artikler",
                ));
            }
        }
    }

    // -- Validering av source_material-metadata ved oppdatering --
    if edge_type == "source_material" {
        if let Some(obj) = metadata.as_object() {
            match obj.get("context").and_then(|v| v.as_str()) {
                Some("quoted" | "summarized" | "referenced") => {}
                Some(other) => {
                    return Err(bad_request(&format!(
                        "source_material context må være 'quoted', 'summarized' eller 'referenced', fikk '{other}'"
                    )));
                }
                None => {
                    return Err(bad_request(
                        "source_material krever 'context'-felt (quoted, summarized eller referenced)",
                    ));
                }
            }
            match obj.get("excerpt").and_then(|v| v.as_str()) {
                Some(s) if !s.trim().is_empty() => {}
                _ => {
                    return Err(bad_request(
                        "source_material krever et ikke-tomt 'excerpt'-felt",
                    ));
                }
            }
        } else {
            return Err(bad_request(
                "source_material krever metadata med 'context' og 'excerpt'",
            ));
        }
    }

    // -- Skriv til PostgreSQL (NOTIFY-trigger sender sanntidsoppdatering) --
    sqlx::query("UPDATE edges SET edge_type = $1, metadata = $2 WHERE id = $3")
        .bind(&edge_type)
        .bind(&metadata)
        .bind(req.edge_id)
        .execute(&state.db)
        .await
        .map_err(|e| {
            tracing::error!("PG-feil ved oppdatering av edge: {e}");
            internal_error("Databasefeil ved oppdatering av edge")
        })?;

    tracing::info!(
        edge_id = %req.edge_id,
        edge_type = %edge_type,
        updated_by = %user.node_id,
        "Edge oppdatert"
    );

    Ok(Json(UpdateEdgeResponse { edge_id: req.edge_id }))
}

// =============================================================================
// delete_edge
// =============================================================================

#[derive(Deserialize)]
pub struct DeleteEdgeRequest {
    /// ID til edgen som skal slettes.
    pub edge_id: Uuid,
}

#[derive(Serialize)]
pub struct DeleteEdgeResponse {
    pub deleted: bool,
}

#[derive(sqlx::FromRow)]
#[allow(dead_code)]
struct FullEdgeRow {
    source_id: Uuid,
    target_id: Uuid,
    edge_type: String,
}

/// POST /intentions/delete_edge
///
/// Sletter en edge. Brukes bl.a. for avpublisering (fjerner belongs_to-edge).
/// Krever at brukeren har opprettet edgen, eller har owner/admin-edge
/// til source-noden.
pub async fn delete_edge(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<DeleteEdgeRequest>,
) -> Result<Json<DeleteEdgeResponse>, (StatusCode, Json<ErrorResponse>)> {
    // -- Tilgangskontroll --
    let can_modify = user_can_modify_edge(&state.db, user.node_id, req.edge_id)
        .await
        .map_err(|e| {
            tracing::error!("PG-feil ved tilgangssjekk: {e}");
            internal_error("Databasefeil ved tilgangssjekk")
        })?;

    if !can_modify {
        return Err(forbidden("Ingen tilgang til å slette denne edgen"));
    }

    // Hent edge-info for logging og publiserings-invalidering
    let edge_info = sqlx::query_as::<_, FullEdgeRow>(
        "SELECT source_id, target_id, edge_type FROM edges WHERE id = $1",
    )
    .bind(req.edge_id)
    .fetch_optional(&state.db)
    .await
    .map_err(|e| {
        tracing::error!("PG-feil ved henting av edge: {e}");
        internal_error("Databasefeil ved henting av edge")
    })?
    .ok_or_else(|| bad_request(&format!("Edge {} finnes ikke", req.edge_id)))?;

    // -- Slett fra PostgreSQL (NOTIFY-trigger sender sanntidsoppdatering) --
    sqlx::query("DELETE FROM edges WHERE id = $1")
        .bind(req.edge_id)
        .execute(&state.db)
        .await
        .map_err(|e| {
            tracing::error!("PG-feil ved sletting av edge: {e}");
            internal_error("Databasefeil ved sletting av edge")
        })?;

    tracing::info!(
        edge_id = %req.edge_id,
        edge_type = %edge_info.edge_type,
        deleted_by = %user.node_id,
        "Edge slettet"
    );

    // Invalider publiserings-cache ved fjerning av belongs_to
    if edge_info.edge_type == "belongs_to" {
        if let Ok(Some(config)) = crate::publishing::find_publishing_collection_by_id(&state.db, edge_info.target_id).await {
            let index_mode = config.index_mode.as_deref().unwrap_or("dynamic");
            if index_mode == "static" {
                let index_payload = serde_json::json!({ "collection_id": edge_info.target_id.to_string() });
                let _ = crate::jobs::enqueue(&state.db, "render_index", index_payload, Some(edge_info.target_id), 4).await;
            } else {
                crate::publishing::invalidate_index_cache(&state.index_cache, edge_info.target_id).await;
            }
        }
    }

    Ok(Json(DeleteEdgeResponse { deleted: true }))
}


// =============================================================================
// set_slot — Redaksjonell slot-håndtering for publiseringssamlinger
// =============================================================================

#[derive(Deserialize)]
pub struct SetSlotRequest {
    /// ID til belongs_to-edgen mellom artikkel og samling.
    pub edge_id: Uuid,
    /// Slot: "hero", "featured", eller null/tom for strøm.
    pub slot: Option<String>,
    /// Rekkefølge innen featured-slot (ignoreres for hero/strøm).
    pub slot_order: Option<i64>,
    /// Forhindrer automatisk fjerning fra slot (FIFO/hero-erstatning).
    pub pinned: Option<bool>,
}

#[derive(Serialize)]
pub struct SetSlotResponse {
    /// Edgen som ble oppdatert.
    pub edge_id: Uuid,
    /// Edges som ble flyttet til strøm pga. hero-erstatning eller featured-overflow.
    pub displaced: Vec<Uuid>,
}

/// POST /intentions/set_slot
///
/// Setter slot-metadata på en belongs_to-edge i en publiseringssamling.
/// Håndhever:
/// - Maks 1 hero: gammel (ikke-pinned) hero flyttes til strøm.
/// - featured_max: eldste (ikke-pinned) featured flyttes til strøm (FIFO).
/// - pinned-flagg beskytter mot automatisk fjerning.
///
/// Krever owner/admin-tilgang til samlingen.
pub async fn set_slot(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<SetSlotRequest>,
) -> Result<Json<SetSlotResponse>, (StatusCode, Json<ErrorResponse>)> {
    // Valider slot-verdi
    let slot = req.slot.as_deref().unwrap_or("");
    if !matches!(slot, "" | "hero" | "featured") {
        return Err(bad_request("slot må være \"hero\", \"featured\", eller null/tom for strøm"));
    }

    // Hent edgen og valider at det er en belongs_to-edge
    #[derive(sqlx::FromRow)]
    struct FullEdgeRow {
        source_id: Uuid,
        target_id: Uuid,
        edge_type: String,
        metadata: serde_json::Value,
    }

    let edge = sqlx::query_as::<_, FullEdgeRow>(
        "SELECT source_id, target_id, edge_type, metadata FROM edges WHERE id = $1",
    )
    .bind(req.edge_id)
    .fetch_optional(&state.db)
    .await
    .map_err(|e| {
        tracing::error!("PG-feil ved henting av edge: {e}");
        internal_error("Databasefeil ved henting av edge")
    })?
    .ok_or_else(|| bad_request(&format!("Edge {} finnes ikke", req.edge_id)))?;

    if edge.edge_type != "belongs_to" {
        return Err(bad_request("set_slot kan kun brukes på belongs_to-edges"));
    }

    let collection_id = edge.target_id;

    // Sjekk at target er en publiseringssamling
    let pub_config = crate::publishing::find_publishing_collection_by_id(&state.db, collection_id)
        .await
        .map_err(|e| {
            tracing::error!("PG-feil ved sjekk av publiseringssamling: {e}");
            internal_error("Databasefeil")
        })?
        .ok_or_else(|| bad_request("Samlingen har ikke publishing-trait"))?;

    // Sjekk at brukeren er owner/admin på samlingen
    let is_admin = sqlx::query_scalar::<_, bool>(
        r#"
        SELECT EXISTS(
            SELECT 1 FROM edges
            WHERE source_id = $1 AND target_id = $2
              AND edge_type IN ('owner', 'admin')
        )
        "#,
    )
    .bind(user.node_id)
    .bind(collection_id)
    .fetch_one(&state.db)
    .await
    .map_err(|e| {
        tracing::error!("PG-feil ved tilgangssjekk: {e}");
        internal_error("Databasefeil ved tilgangssjekk")
    })?;

    if !is_admin {
        return Err(forbidden("Kun owner/admin kan endre slots"));
    }

    let featured_max = pub_config.featured_max.unwrap_or(4);
    let mut displaced: Vec<Uuid> = Vec::new();

    // -- Slot-logikk: håndter hero-erstatning og featured-overflow --

    if slot == "hero" {
        // Finn eksisterende hero-edges (som ikke er denne edgen)
        let existing_heroes: Vec<(Uuid, serde_json::Value)> = sqlx::query_as(
            r#"
            SELECT id, metadata FROM edges
            WHERE target_id = $1
              AND edge_type = 'belongs_to'
              AND metadata->>'slot' = 'hero'
              AND id != $2
            "#,
        )
        .bind(collection_id)
        .bind(req.edge_id)
        .fetch_all(&state.db)
        .await
        .map_err(|e| {
            tracing::error!("PG-feil ved hero-sjekk: {e}");
            internal_error("Databasefeil")
        })?;

        // Flytt ikke-pinned heroes tilbake til strøm
        for (hero_edge_id, hero_meta) in &existing_heroes {
            let pinned = hero_meta.get("pinned").and_then(|v| v.as_bool()).unwrap_or(false);
            if !pinned {
                displace_to_stream(&state.db, *hero_edge_id, hero_meta).await?;
                displaced.push(*hero_edge_id);
            }
        }
    } else if slot == "featured" {
        // Tell nåværende featured-edges (ekskluder denne edgen om den allerede er featured)
        let current_featured: Vec<(Uuid, serde_json::Value, Option<i64>)> = sqlx::query_as(
            r#"
            SELECT id, metadata,
                   (metadata->>'slot_order')::bigint as slot_order
            FROM edges
            WHERE target_id = $1
              AND edge_type = 'belongs_to'
              AND metadata->>'slot' = 'featured'
              AND id != $2
            ORDER BY (metadata->>'slot_order')::int ASC NULLS LAST,
                     created_at ASC
            "#,
        )
        .bind(collection_id)
        .bind(req.edge_id)
        .fetch_all(&state.db)
        .await
        .map_err(|e| {
            tracing::error!("PG-feil ved featured-sjekk: {e}");
            internal_error("Databasefeil")
        })?;

        // Hvis vi legger til en ny featured og det overstiger featured_max,
        // fjern eldste (FIFO) ikke-pinned featured
        let new_count = current_featured.len() as i64 + 1; // +1 for den vi setter nå
        if new_count > featured_max {
            let overflow = (new_count - featured_max) as usize;
            // Finn de eldste ikke-pinned featured-edges å fjerne (FIFO = sist i listen)
            let removable: Vec<&(Uuid, serde_json::Value, Option<i64>)> = current_featured
                .iter()
                .rev() // Høyest slot_order / eldst først for FIFO
                .filter(|(_, meta, _)| {
                    !meta.get("pinned").and_then(|v| v.as_bool()).unwrap_or(false)
                })
                .take(overflow)
                .collect();

            for (feat_edge_id, feat_meta, _) in removable {
                displace_to_stream(&state.db, *feat_edge_id, feat_meta).await?;
                displaced.push(*feat_edge_id);
            }
        }
    }

    // -- Oppdater edgen med ny slot-metadata --
    let mut new_meta = edge.metadata.clone();
    if let Some(obj) = new_meta.as_object_mut() {
        if slot.is_empty() {
            obj.remove("slot");
            obj.remove("slot_order");
        } else {
            obj.insert("slot".to_string(), serde_json::json!(slot));
            if slot == "featured" {
                if let Some(order) = req.slot_order {
                    obj.insert("slot_order".to_string(), serde_json::json!(order));
                }
            } else {
                obj.remove("slot_order");
            }
        }
        // Sett/fjern pinned
        match req.pinned {
            Some(true) => { obj.insert("pinned".to_string(), serde_json::json!(true)); }
            Some(false) => { obj.remove("pinned"); }
            None => {} // Behold eksisterende
        }
    }

    // Skriv til PG (NOTIFY-trigger sender sanntidsoppdatering)
    sqlx::query("UPDATE edges SET metadata = $1 WHERE id = $2")
        .bind(&new_meta)
        .bind(req.edge_id)
        .execute(&state.db)
        .await
        .map_err(|e| {
            tracing::error!("PG-feil ved slot-oppdatering: {e}");
            internal_error("Databasefeil ved slot-oppdatering")
        })?;

    tracing::info!(
        edge_id = %req.edge_id,
        slot = %slot,
        displaced_count = displaced.len(),
        "Slot oppdatert"
    );

    // Trigger forside-rerendering
    trigger_render_if_publishing(&state.db, &state.index_cache, edge.source_id, collection_id).await;

    Ok(Json(SetSlotResponse {
        edge_id: req.edge_id,
        displaced,
    }))
}

/// Flytter en edge fra sin nåværende slot tilbake til strøm (slot=null).
/// Beholder annen metadata (publish_at, approved_by, etc.).
async fn displace_to_stream(
    db: &PgPool,
    edge_id: Uuid,
    current_meta: &serde_json::Value,
) -> Result<(), (StatusCode, Json<ErrorResponse>)> {
    let mut meta = current_meta.clone();
    if let Some(obj) = meta.as_object_mut() {
        obj.remove("slot");
        obj.remove("slot_order");
        // pinned er irrelevant i strøm, fjern det
        obj.remove("pinned");
    }

    sqlx::query("UPDATE edges SET metadata = $1 WHERE id = $2")
        .bind(&meta)
        .bind(edge_id)
        .execute(db)
        .await
        .map_err(|e| {
            tracing::error!("PG-feil ved displace_to_stream: {e}");
            internal_error("Databasefeil ved fjerning fra slot")
        })?;

    tracing::info!(edge_id = %edge_id, "Edge fjernet fra slot → strøm");
    Ok(())
}

// =============================================================================
// create_communication
// =============================================================================

#[derive(Deserialize)]
pub struct CreateCommunicationRequest {
    /// Visningstittel for kommunikasjonsnoden (f.eks. "Redaksjonsmøte").
    pub title: Option<String>,
    /// Deltakere — liste med node_id-er (person-noder).
    /// Innlogget bruker legges automatisk til som owner.
    pub participants: Vec<Uuid>,
    /// Synlighet. Default: "hidden" (privat).
    pub visibility: Option<String>,
    /// Kontekst-node (f.eks. artikkel). Gir automatisk belongs_to-edge
    /// fra kommunikasjonsnoden til kontekstnoden.
    pub context_id: Option<Uuid>,
}

#[derive(Serialize)]
pub struct CreateCommunicationResponse {
    pub node_id: Uuid,
    /// Edge-IDer for opprettede deltaker-edges (owner + member_of).
    pub edge_ids: Vec<Uuid>,
}

/// POST /intentions/create_communication
///
/// Oppretter en kommunikasjonsnode med deltaker-edges.
/// Innlogget bruker blir automatisk owner. Andre deltakere får member_of-edge.
/// Metadata inneholder started_at-tidsstempel.
///
/// Ref: docs/primitiver/nodes.md (communication), docs/retninger/universell_input.md
pub async fn create_communication(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<CreateCommunicationRequest>,
) -> Result<Json<CreateCommunicationResponse>, (StatusCode, Json<ErrorResponse>)> {
    let visibility = req.visibility.unwrap_or_else(|| "hidden".to_string());
    if !VALID_VISIBILITIES.contains(&visibility.as_str()) {
        return Err(bad_request(&format!(
            "Ugyldig visibility: '{visibility}'. Gyldige verdier: {VALID_VISIBILITIES:?}"
        )));
    }

    // Valider at alle deltakere eksisterer
    for participant_id in &req.participants {
        let exists = node_exists(&state.db, *participant_id)
            .await
            .map_err(|e| {
                tracing::error!("PG-feil ved nodesjekk: {e}");
                internal_error("Databasefeil ved validering")
            })?;
        if !exists {
            return Err(bad_request(&format!(
                "Deltaker-node {} finnes ikke",
                participant_id
            )));
        }
    }

    let title = req.title.unwrap_or_default();
    let now = chrono::Utc::now();
    let metadata = serde_json::json!({ "started_at": now.to_rfc3339() });

    // -- Opprett kommunikasjonsnoden --
    let node_id = Uuid::now_v7();

    sqlx::query(
        r#"INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
           VALUES ($1, 'communication', NULLIF($2, ''), '', $3::visibility, $4, $5)"#,
    )
    .bind(node_id)
    .bind(&title)
    .bind(&visibility)
    .bind(&metadata)
    .bind(user.node_id)
    .execute(&state.db)
    .await
    .map_err(|e| {
        tracing::error!("PG-feil ved opprettelse av kommunikasjonsnode: {e}");
        internal_error("Databasefeil ved opprettelse av kommunikasjonsnode")
    })?;

    tracing::info!(
        node_id = %node_id,
        created_by = %user.node_id,
        participants = ?req.participants,
        "Kommunikasjonsnode opprettet"
    );

    // -- Opprett deltaker-edges --
    let mut edge_ids = Vec::new();

    // Owner-edge for innlogget bruker (med recompute_access)
    let owner_edge_id = Uuid::now_v7();
    edge_ids.push(owner_edge_id);

    {
        let mut tx = state.db.begin().await.map_err(|e| {
            tracing::error!("PG begin: {e}");
            internal_error("Databasefeil")
        })?;

        sqlx::query(
            r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
               VALUES ($1, $2, $3, 'owner', '{}', false, $4)"#,
        )
        .bind(owner_edge_id)
        .bind(user.node_id)
        .bind(node_id)
        .bind(user.node_id)
        .execute(&mut *tx)
        .await
        .map_err(|e| {
            tracing::error!("PG insert owner edge: {e}");
            internal_error("Databasefeil ved opprettelse av owner-edge")
        })?;

        sqlx::query("SELECT recompute_access($1, $2, 'owner'::access_level, $3)")
            .bind(user.node_id)
            .bind(node_id)
            .bind(owner_edge_id)
            .execute(&mut *tx)
            .await
            .map_err(|e| {
                tracing::error!("recompute_access: {e}");
                internal_error("Databasefeil ved tilgangsberegning")
            })?;

        tx.commit().await.map_err(|e| {
            tracing::error!("PG commit: {e}");
            internal_error("Databasefeil")
        })?;
    }

    // member_of-edges for øvrige deltakere
    for participant_id in &req.participants {
        // Hopp over innlogget bruker — allerede owner
        if *participant_id == user.node_id {
            continue;
        }

        let edge_id = Uuid::now_v7();
        edge_ids.push(edge_id);

        let mut tx = state.db.begin().await.map_err(|e| {
            tracing::error!("PG begin: {e}");
            internal_error("Databasefeil")
        })?;

        sqlx::query(
            r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
               VALUES ($1, $2, $3, 'member_of', '{}', false, $4)"#,
        )
        .bind(edge_id)
        .bind(*participant_id)
        .bind(node_id)
        .bind(user.node_id)
        .execute(&mut *tx)
        .await
        .map_err(|e| {
            tracing::error!("PG insert member_of edge: {e}");
            internal_error("Databasefeil ved opprettelse av member_of-edge")
        })?;

        sqlx::query("SELECT recompute_access($1, $2, 'member'::access_level, $3)")
            .bind(*participant_id)
            .bind(node_id)
            .bind(edge_id)
            .execute(&mut *tx)
            .await
            .map_err(|e| {
                tracing::error!("recompute_access: {e}");
                internal_error("Databasefeil ved tilgangsberegning")
            })?;

        tx.commit().await.map_err(|e| {
            tracing::error!("PG commit: {e}");
            internal_error("Databasefeil")
        })?;
    }

    // -- Opprett belongs_to-edge til kontekstnode (f.eks. artikkel) --
    if let Some(context_id) = req.context_id {
        let ctx_exists = node_exists(&state.db, context_id)
            .await
            .map_err(|e| {
                tracing::error!("PG-feil ved kontekstnode-sjekk: {e}");
                internal_error("Databasefeil ved validering")
            })?;
        if !ctx_exists {
            return Err(bad_request(&format!(
                "Kontekst-node {} finnes ikke",
                context_id
            )));
        }

        let ctx_edge_id = Uuid::now_v7();
        edge_ids.push(ctx_edge_id);

        sqlx::query(
            r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
               VALUES ($1, $2, $3, 'belongs_to', '{}', false, $4)"#,
        )
        .bind(ctx_edge_id)
        .bind(node_id)
        .bind(context_id)
        .bind(user.node_id)
        .execute(&state.db)
        .await
        .map_err(|e| {
            tracing::error!("PG insert belongs_to edge: {e}");
            internal_error("Databasefeil ved opprettelse av edge")
        })?;

        tracing::info!(
            communication_id = %node_id,
            context_id = %context_id,
            "belongs_to-edge opprettet til kontekstnode"
        );

        // Propager tilgang fra kontekstnoden
        if let Err(e) = sqlx::query("SELECT propagate_belongs_to_access($1, $2)")
            .bind(node_id)
            .bind(context_id)
            .execute(&state.db)
            .await
        {
            tracing::error!("propagate_belongs_to_access feilet: {e}");
        }
    }

    tracing::info!(
        node_id = %node_id,
        edge_count = edge_ids.len(),
        "Kommunikasjonsnode med deltaker-edges opprettet"
    );

    Ok(Json(CreateCommunicationResponse { node_id, edge_ids }))
}

// =============================================================================
// upload_media
// =============================================================================

#[derive(Serialize)]
pub struct UploadMediaResponse {
    /// ID til den opprettede media-noden.
    pub media_node_id: Uuid,
    /// SHA-256 hash (CAS-nøkkel).
    pub cas_hash: String,
    /// Filstørrelse i bytes.
    pub size_bytes: u64,
    /// `true` hvis filen allerede fantes i CAS (deduplisert).
    pub already_existed: bool,
    /// Edge-ID for `has_media`-edge (kun hvis source_id ble oppgitt).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub has_media_edge_id: Option<Uuid>,
}

/// POST /intentions/upload_media
///
/// Mottar en fil via multipart form data, lagrer i CAS, oppretter en
/// media-node med CAS-metadata. Hvis `source_id` er oppgitt, opprettes
/// en `has_media`-edge fra kildenoden til den nye media-noden.
///
/// Multipart-felter:
/// - `file` (påkrevd): Binærfilen som skal lastes opp.
/// - `source_id` (valgfritt): Node-ID å koble media til via `has_media`-edge.
/// - `visibility` (valgfritt): Synlighet for media-noden. Default: "hidden".
/// - `title` (valgfritt): Tittel for media-noden (default: filnavn).
/// - `metadata_extra` (valgfritt): JSON-objekt med ekstra metadata som merges
///   inn i media-nodens metadata (f.eks. `{"source":"screenshot"}`).
///
/// Ref: docs/primitiver/nodes.md (media), docs/retninger/universell_input.md
pub async fn upload_media(
    State(state): State<AppState>,
    user: AuthUser,
    mut multipart: Multipart,
) -> Result<Json<UploadMediaResponse>, (StatusCode, Json<ErrorResponse>)> {
    let mut file_data: Option<Vec<u8>> = None;
    let mut file_name: Option<String> = None;
    let mut content_type: Option<String> = None;
    let mut source_id: Option<Uuid> = None;
    let mut visibility = "hidden".to_string();
    let mut title: Option<String> = None;
    let mut metadata_extra: Option<serde_json::Value> = None;

    // -- Parse multipart-felter --
    while let Some(field) = multipart.next_field().await.map_err(|e| {
        bad_request(&format!("Ugyldig multipart-data: {e}"))
    })? {
        let field_name = field.name().unwrap_or("").to_string();

        match field_name.as_str() {
            "file" => {
                file_name = field.file_name().map(|s| s.to_string());
                content_type = field.content_type().map(|s| s.to_string());
                let bytes = field.bytes().await.map_err(|e| {
                    bad_request(&format!("Kunne ikke lese fil: {e}"))
                })?;
                if bytes.len() > MAX_UPLOAD_SIZE {
                    return Err(bad_request(&format!(
                        "Filen er for stor: {} bytes (maks {} bytes)",
                        bytes.len(),
                        MAX_UPLOAD_SIZE
                    )));
                }
                if bytes.is_empty() {
                    return Err(bad_request("Filen er tom"));
                }
                file_data = Some(bytes.to_vec());
            }
            "source_id" => {
                let text = field.text().await.map_err(|e| {
                    bad_request(&format!("Kunne ikke lese source_id: {e}"))
                })?;
                let id = Uuid::parse_str(&text).map_err(|_| {
                    bad_request(&format!("Ugyldig source_id UUID: '{text}'"))
                })?;
                source_id = Some(id);
            }
            "visibility" => {
                let text = field.text().await.map_err(|e| {
                    bad_request(&format!("Kunne ikke lese visibility: {e}"))
                })?;
                if !VALID_VISIBILITIES.contains(&text.as_str()) {
                    return Err(bad_request(&format!(
                        "Ugyldig visibility: '{text}'. Gyldige verdier: {VALID_VISIBILITIES:?}"
                    )));
                }
                visibility = text;
            }
            "title" => {
                let text = field.text().await.map_err(|e| {
                    bad_request(&format!("Kunne ikke lese title: {e}"))
                })?;
                title = Some(text);
            }
            "metadata_extra" => {
                let text = field.text().await.map_err(|e| {
                    bad_request(&format!("Kunne ikke lese metadata_extra: {e}"))
                })?;
                let parsed: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
                    bad_request(&format!("Ugyldig JSON i metadata_extra: {e}"))
                })?;
                if !parsed.is_object() {
                    return Err(bad_request("metadata_extra må være et JSON-objekt"));
                }
                metadata_extra = Some(parsed);
            }
            _ => {
                // Ignorer ukjente felter
            }
        }
    }

    let data = file_data.ok_or_else(|| bad_request("Mangler 'file'-felt i multipart-data"))?;

    // -- Valider source_id hvis oppgitt --
    if let Some(src_id) = source_id {
        let exists = node_exists(&state.db, src_id).await.map_err(|e| {
            tracing::error!("PG-feil ved nodesjekk: {e}");
            internal_error("Databasefeil ved validering av source_id")
        })?;
        if !exists {
            return Err(bad_request(&format!("source_id {} finnes ikke", src_id)));
        }
    }

    // -- Lagre i CAS --
    let cas_result = state.cas.store(&data).await.map_err(|e| {
        tracing::error!("CAS-lagring feilet: {e}");
        internal_error(&format!("Kunne ikke lagre fil i CAS: {e}"))
    })?;

    // -- Opprett media-node --
    let media_node_id = Uuid::now_v7();
    let mime = content_type.unwrap_or_else(|| "application/octet-stream".to_string());
    let node_title = title.unwrap_or_else(|| file_name.unwrap_or_default());

    let mut metadata = serde_json::json!({
        "cas_hash": cas_result.hash,
        "mime": mime,
        "size_bytes": cas_result.size,
    });

    // Merge ekstra metadata fra klienten (f.eks. source: "screenshot")
    if let Some(extra) = &metadata_extra {
        if let Some(obj) = extra.as_object() {
            for (k, v) in obj {
                metadata[k] = v.clone();
            }
        }
    }

    // Skriv media-node til PG
    sqlx::query(
        r#"INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
           VALUES ($1, 'media', NULLIF($2, ''), '', $3::visibility, $4, $5)"#,
    )
    .bind(media_node_id)
    .bind(&node_title)
    .bind(&visibility)
    .bind(&metadata)
    .bind(user.node_id)
    .execute(&state.db)
    .await
    .map_err(|e| {
        tracing::error!("PG-feil ved opprettelse av media-node: {e}");
        internal_error("Databasefeil ved opprettelse av media-node")
    })?;

    tracing::info!(
        media_node_id = %media_node_id,
        cas_hash = %cas_result.hash,
        size = cas_result.size,
        mime = %mime,
        already_existed = cas_result.already_existed,
        created_by = %user.node_id,
        "Media-node opprettet"
    );

    // -- Opprett has_media-edge hvis source_id er oppgitt --
    let has_media_edge_id = if let Some(src_id) = source_id {
        let 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, 'has_media', '{}', false, $4)"#,
        )
        .bind(edge_id)
        .bind(src_id)
        .bind(media_node_id)
        .bind(user.node_id)
        .execute(&state.db)
        .await
        .map_err(|e| {
            tracing::error!("PG insert has_media edge: {e}");
            internal_error("Databasefeil ved opprettelse av has_media-edge")
        })?;

        tracing::info!(
            edge_id = %edge_id,
            source_id = %src_id,
            media_node_id = %media_node_id,
            "has_media-edge opprettet"
        );

        Some(edge_id)
    } else {
        None
    };

    // -- Logg CAS-ressursforbruk (kun nye filer, ikke dedup) --
    if !cas_result.already_existed {
        let cas_collection_id = if let Some(src_id) = source_id {
            crate::resource_usage::find_collection_for_node(&state.db, src_id).await
        } else {
            None
        };
        if let Err(e) = crate::resource_usage::log(
            &state.db,
            media_node_id,
            Some(user.node_id),
            cas_collection_id,
            "cas",
            serde_json::json!({
                "hash": cas_result.hash,
                "size_bytes": cas_result.size,
                "mime": mime,
                "operation": "store"
            }),
        )
        .await
        {
            tracing::warn!(error = %e, "Kunne ikke logge CAS-ressursforbruk");
        }
    }

    // -- Enqueue AI-beskrivelse for skjermklipp --
    let is_screenshot = metadata_extra
        .as_ref()
        .and_then(|v| v.get("source"))
        .and_then(|v| v.as_str())
        == Some("screenshot");
    let is_image = mime.starts_with("image/");

    if is_screenshot && is_image {
        let payload = serde_json::json!({
            "media_node_id": media_node_id,
            "cas_hash": cas_result.hash,
            "mime": mime,
        });

        let collection_id = if let Some(src_id) = source_id {
            find_collection_for_node(&state.db, src_id).await.ok().flatten()
        } else {
            None
        };

        match crate::jobs::enqueue(&state.db, "describe_image", payload, collection_id, 5).await {
            Ok(job_id) => {
                tracing::info!(
                    job_id = %job_id,
                    media_node_id = %media_node_id,
                    "describe_image-jobb opprettet for skjermklipp"
                );
            }
            Err(e) => {
                tracing::error!(
                    media_node_id = %media_node_id,
                    error = %e,
                    "Kunne ikke opprette describe_image-jobb"
                );
            }
        }
    }

    // -- Enqueue transkripsjons-jobb for lydfiler --
    if is_audio_mime(&mime) {
        let payload = serde_json::json!({
            "media_node_id": media_node_id,
            "cas_hash": cas_result.hash,
            "mime": mime,
            "language": "no",
        });

        // Finn collection_node_id fra source_id sin eier-kjede (valgfritt)
        let collection_id = if let Some(src_id) = source_id {
            find_collection_for_node(&state.db, src_id).await.ok().flatten()
        } else {
            None
        };

        match crate::jobs::enqueue(&state.db, "whisper_transcribe", payload, collection_id, 5).await {
            Ok(job_id) => {
                tracing::info!(
                    job_id = %job_id,
                    media_node_id = %media_node_id,
                    "Transkripsjons-jobb opprettet"
                );
            }
            Err(e) => {
                // Ikke feil ut hele uploaden — logg og fortsett
                tracing::error!(
                    media_node_id = %media_node_id,
                    error = %e,
                    "Kunne ikke opprette transkripsjons-jobb"
                );
            }
        }
    }

    Ok(Json(UploadMediaResponse {
        media_node_id,
        cas_hash: cas_result.hash,
        size_bytes: cas_result.size,
        already_existed: cas_result.already_existed,
        has_media_edge_id,
    }))
}

/// Sjekker om en MIME-type er en lydtype som Whisper kan transkribere.
fn is_audio_mime(mime: &str) -> bool {
    mime.starts_with("audio/")
}

/// Forsøker å finne collection_node_id for en node via belongs_to-edges.
async fn find_collection_for_node(db: &PgPool, node_id: Uuid) -> Result<Option<Uuid>, sqlx::Error> {
    let row = sqlx::query_scalar::<_, Uuid>(
        r#"
        SELECT e.target_id
        FROM edges e
        JOIN nodes n ON n.id = e.target_id
        WHERE e.source_id = $1
          AND e.edge_type = 'belongs_to'
          AND n.node_kind = 'collection'
        LIMIT 1
        "#,
    )
    .bind(node_id)
    .fetch_optional(db)
    .await?;

    Ok(row)
}

// =============================================================================
// create_alias
// =============================================================================

#[derive(Deserialize)]
pub struct CreateAliasRequest {
    /// Visningsnavn for aliaset (f.eks. "Bjørn" for en podcastvert-identitet).
    pub title: String,
    /// Valgfri metadata (f.eks. display_name, bio, avatar).
    pub metadata: Option<serde_json::Value>,
}

#[derive(Serialize)]
pub struct CreateAliasResponse {
    /// ID til den nye alias-noden.
    pub alias_node_id: Uuid,
    /// ID til alias-edgen (system=true, usynlig for traversering).
    pub alias_edge_id: Uuid,
}

/// POST /intentions/create_alias
///
/// Oppretter en alias-node (node_kind='person') og en `alias`-edge
/// (system=true) fra brukerens hovednode til aliasnoden. Alias-edgen
/// er usynlig for traversering — RLS-policyen filtrerer system-edges.
///
/// Bruksområde: en bruker kan ha flere identiteter (f.eks. Vegard
/// som seg selv og "Bjørn" som podcastvert). Oppgave 8.2 vil bruke
/// aliaset til å sette created_by kontekstbasert.
///
/// Ref: docs/primitiver/edges.md (systemedges), docs/primitiver/nodes.md
pub async fn create_alias(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<CreateAliasRequest>,
) -> Result<Json<CreateAliasResponse>, (StatusCode, Json<ErrorResponse>)> {
    let title = req.title.trim().to_string();
    if title.is_empty() {
        return Err(bad_request("Alias-tittel kan ikke være tom"));
    }

    let metadata = req.metadata.unwrap_or_else(|| serde_json::json!({}));

    // -- Generer IDer --
    let alias_node_id = Uuid::now_v7();
    let alias_edge_id = Uuid::now_v7();

    // -- Skriv alias-node og alias-edge til PG --
    sqlx::query(
        r#"INSERT INTO nodes (id, node_kind, title, visibility, metadata, created_by)
           VALUES ($1, 'person', $2, 'hidden'::visibility, $3, $4)"#,
    )
    .bind(alias_node_id)
    .bind(&title)
    .bind(&metadata)
    .bind(user.node_id)
    .execute(&state.db)
    .await
    .map_err(|e| {
        tracing::error!("PG-feil ved opprettelse av alias-node: {e}");
        internal_error("Databasefeil ved opprettelse av alias")
    })?;

    sqlx::query(
        r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
           VALUES ($1, $2, $3, 'alias', '{}', true, $4)"#,
    )
    .bind(alias_edge_id)
    .bind(user.node_id)
    .bind(alias_node_id)
    .bind(user.node_id)
    .execute(&state.db)
    .await
    .map_err(|e| {
        tracing::error!("PG-feil ved opprettelse av alias-edge: {e}");
        internal_error("Databasefeil ved opprettelse av alias-edge")
    })?;

    tracing::info!(
        alias_node_id = %alias_node_id,
        alias_edge_id = %alias_edge_id,
        user_node_id = %user.node_id,
        title = %title,
        "Alias opprettet"
    );

    Ok(Json(CreateAliasResponse {
        alias_node_id,
        alias_edge_id,
    }))
}

// =============================================================================
// Bakgrunns-PG-operasjoner
// =============================================================================

#[derive(sqlx::FromRow)]
struct NodeRow {
    node_kind: String,
    title: Option<String>,
    content: Option<String>,
    visibility: String,
    metadata: serde_json::Value,
}

/// Enkel rad for å sjekke node_kind (brukes ved context_id-validering).
#[derive(sqlx::FromRow)]
struct NodeKindRow {
    node_kind: String,
}



/// Sjekker om target er en samling med publishing-trait, og legger i så fall
/// en `render_article`-jobb i køen. For statisk modus legges også en
/// `render_index`-jobb. For dynamisk modus invalideres in-memory-cachen.
async fn trigger_render_if_publishing(
    db: &PgPool,
    index_cache: &crate::publishing::IndexCache,
    source_id: Uuid,
    target_id: Uuid,
) {
    match crate::publishing::find_publishing_collection_by_id(db, target_id).await {
        Ok(Some(config)) => {
            // Render artikkelen
            let article_payload = serde_json::json!({
                "node_id": source_id.to_string(),
                "collection_id": target_id.to_string(),
            });

            match crate::jobs::enqueue(db, "render_article", article_payload, Some(target_id), 5).await {
                Ok(job_id) => {
                    tracing::info!(
                        job_id = %job_id,
                        node_id = %source_id,
                        collection_id = %target_id,
                        "render_article-jobb lagt i kø"
                    );
                }
                Err(e) => {
                    tracing::error!(
                        node_id = %source_id,
                        collection_id = %target_id,
                        error = %e,
                        "Kunne ikke legge render_article-jobb i kø"
                    );
                }
            }

            // Re-render forsiden
            let index_mode = config.index_mode.as_deref().unwrap_or("dynamic");
            if index_mode == "static" {
                // Statisk modus: legg render_index-jobb i køen
                let index_payload = serde_json::json!({
                    "collection_id": target_id.to_string(),
                });

                match crate::jobs::enqueue(db, "render_index", index_payload, Some(target_id), 4).await {
                    Ok(job_id) => {
                        tracing::info!(
                            job_id = %job_id,
                            collection_id = %target_id,
                            "render_index-jobb lagt i kø (statisk modus)"
                        );
                    }
                    Err(e) => {
                        tracing::error!(
                            collection_id = %target_id,
                            error = %e,
                            "Kunne ikke legge render_index-jobb i kø"
                        );
                    }
                }
            } else {
                // Dynamisk modus: invalider in-memory-cache
                crate::publishing::invalidate_index_cache(index_cache, target_id).await;
            }
        }
        Ok(None) => {
            // Target er ikke en publiseringssamling — ingen rendering nødvendig
        }
        Err(e) => {
            tracing::error!(
                target_id = %target_id,
                error = %e,
                "Feil ved sjekk av publiseringssamling for rendering-trigger"
            );
        }
    }
}


// =============================================================================
// POST /intentions/update_segment — rediger transkripsjons-segment
// =============================================================================

#[derive(Deserialize)]
pub struct UpdateSegmentRequest {
    /// Segment-ID (primary key i transcription_segments).
    pub segment_id: i64,
    /// Ny tekst for segmentet.
    pub content: String,
}

#[derive(Serialize)]
pub struct UpdateSegmentResponse {
    pub segment_id: i64,
    pub edited: bool,
}

/// POST /intentions/update_segment
///
/// Oppdaterer teksten i et transkripsjons-segment og setter `edited = true`.
/// Krever at brukeren har skrivetilgang til noden segmentet tilhører.
pub async fn update_segment(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<UpdateSegmentRequest>,
) -> Result<Json<UpdateSegmentResponse>, (StatusCode, Json<ErrorResponse>)> {
    let content = req.content.trim().to_string();
    if content.is_empty() {
        return Err(bad_request("Innhold kan ikke være tomt"));
    }

    // Finn noden dette segmentet tilhører
    let segment_node: Option<(Uuid,)> = sqlx::query_as(
        "SELECT node_id FROM transcription_segments WHERE id = $1",
    )
    .bind(req.segment_id)
    .fetch_optional(&state.db)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "Feil ved oppslag av segment");
        internal_error("Databasefeil")
    })?;

    let Some((node_id,)) = segment_node else {
        return Err(bad_request("Segment finnes ikke"));
    };

    // Verifiser skrivetilgang
    let can_modify = user_can_modify_node(&state.db, user.node_id, node_id)
        .await
        .map_err(|e| {
            tracing::error!(error = %e, "Tilgangssjekk feilet");
            internal_error("Databasefeil ved tilgangssjekk")
        })?;

    if !can_modify {
        return Err(forbidden("Ikke tilgang til å redigere dette segmentet"));
    }

    // Oppdater segmentet
    sqlx::query(
        "UPDATE transcription_segments SET content = $1, edited = true WHERE id = $2",
    )
    .bind(&content)
    .bind(req.segment_id)
    .execute(&state.db)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "Kunne ikke oppdatere segment");
        internal_error("Databasefeil ved oppdatering")
    })?;

    tracing::info!(
        segment_id = req.segment_id,
        node_id = %node_id,
        user = %user.node_id,
        "Segment redigert"
    );

    Ok(Json(UpdateSegmentResponse {
        segment_id: req.segment_id,
        edited: true,
    }))
}

// =============================================================================
// POST /intentions/retranscribe — trigger re-transkripsjon for eksisterende media-node
// =============================================================================

#[derive(Deserialize)]
pub struct RetranscribeRequest {
    /// Media-node-ID å re-transkribere.
    pub node_id: Uuid,
}

#[derive(Serialize)]
pub struct RetranscribeResponse {
    pub job_id: Uuid,
}

/// POST /intentions/retranscribe
///
/// Trigger en ny transkripsjons-jobb for en eksisterende media-node.
/// Henter CAS-hash og MIME fra nodens metadata. Krever skrivetilgang.
pub async fn retranscribe(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<RetranscribeRequest>,
) -> Result<Json<RetranscribeResponse>, (StatusCode, Json<ErrorResponse>)> {
    // Verifiser skrivetilgang
    let can_modify = user_can_modify_node(&state.db, user.node_id, req.node_id)
        .await
        .map_err(|e| {
            tracing::error!(error = %e, "Tilgangssjekk feilet");
            internal_error("Databasefeil ved tilgangssjekk")
        })?;

    if !can_modify {
        return Err(forbidden("Ikke tilgang til å re-transkribere denne noden"));
    }

    // Hent metadata for CAS-hash og MIME
    let node: Option<(serde_json::Value,)> = sqlx::query_as(
        "SELECT metadata FROM nodes WHERE id = $1 AND node_kind = 'media'",
    )
    .bind(req.node_id)
    .fetch_optional(&state.db)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "Feil ved henting av node");
        internal_error("Databasefeil")
    })?;

    let Some((metadata,)) = node else {
        return Err(bad_request("Noden finnes ikke eller er ikke en media-node"));
    };

    let cas_hash = metadata["cas_hash"]
        .as_str()
        .ok_or_else(|| bad_request("Noden mangler cas_hash i metadata"))?;
    let mime = metadata["mime"]
        .as_str()
        .unwrap_or("audio/mpeg");

    // Finn collection fra eier-kjede
    let collection_id = find_collection_for_node(&state.db, req.node_id)
        .await
        .ok()
        .flatten();

    let payload = serde_json::json!({
        "media_node_id": req.node_id,
        "cas_hash": cas_hash,
        "mime": mime,
        "language": "no",
    });

    let job_id = crate::jobs::enqueue(&state.db, "whisper_transcribe", payload, collection_id, 5)
        .await
        .map_err(|e| {
            tracing::error!(error = %e, "Kunne ikke opprette re-transkripsjons-jobb");
            internal_error("Kunne ikke starte re-transkripsjon")
        })?;

    tracing::info!(
        job_id = %job_id,
        node_id = %req.node_id,
        user = %user.node_id,
        "Re-transkripsjons-jobb opprettet"
    );

    Ok(Json(RetranscribeResponse { job_id }))
}

// =============================================================================
// POST /intentions/resolve_retranscription — anvend brukerens segment-valg
// =============================================================================

#[derive(Deserialize)]
pub struct SegmentChoice {
    /// Sekvensnummer i den nye transkripsjonen.
    pub seq: i32,
    /// "new" = behold ny versjon, "old" = behold gammel versjon.
    pub choice: String,
}

#[derive(Deserialize)]
pub struct ResolveRetranscriptionRequest {
    /// Media-node-ID.
    pub node_id: Uuid,
    /// `transcribed_at` for den nye versjonen.
    pub new_version: String,
    /// `transcribed_at` for den gamle versjonen.
    pub old_version: String,
    /// Per-segment-valg.
    pub choices: Vec<SegmentChoice>,
}

#[derive(Serialize)]
pub struct ResolveRetranscriptionResponse {
    pub resolved: bool,
    pub kept_old: i32,
    pub kept_new: i32,
}

/// POST /intentions/resolve_retranscription
///
/// Anvender brukerens per-segment-valg etter re-transkripsjon.
/// For segmenter der brukeren velger "old", kopieres innholdet fra
/// den gamle versjonen til den nye. Gamle versjoner slettes etterpå.
pub async fn resolve_retranscription(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<ResolveRetranscriptionRequest>,
) -> Result<Json<ResolveRetranscriptionResponse>, (StatusCode, Json<ErrorResponse>)> {
    // Verifiser skrivetilgang
    let can_modify = user_can_modify_node(&state.db, user.node_id, req.node_id)
        .await
        .map_err(|e| {
            tracing::error!(error = %e, "Tilgangssjekk feilet");
            internal_error("Databasefeil ved tilgangssjekk")
        })?;

    if !can_modify {
        return Err(forbidden("Ikke tilgang til å endre segmenter"));
    }

    let new_ts: chrono::DateTime<chrono::Utc> = req.new_version.parse()
        .map_err(|_| bad_request("Ugyldig new_version-tidsstempel"))?;
    let old_ts: chrono::DateTime<chrono::Utc> = req.old_version.parse()
        .map_err(|_| bad_request("Ugyldig old_version-tidsstempel"))?;

    // Hent gamle segmenter (indeksert på seq)
    let old_segments: Vec<(i32, String, bool)> = sqlx::query_as(
        "SELECT seq, content, edited FROM transcription_segments WHERE node_id = $1 AND transcribed_at = $2 ORDER BY seq",
    )
    .bind(req.node_id)
    .bind(old_ts)
    .fetch_all(&state.db)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "Feil ved henting av gamle segmenter");
        internal_error("Databasefeil")
    })?;

    let old_by_seq: std::collections::HashMap<i32, (String, bool)> = old_segments
        .into_iter()
        .map(|(seq, content, edited)| (seq, (content, edited)))
        .collect();

    let mut kept_old = 0i32;
    let mut kept_new = 0i32;

    let mut tx = state.db.begin().await.map_err(|e| {
        tracing::error!(error = %e, "Transaksjon feilet");
        internal_error("Databasefeil")
    })?;

    for choice in &req.choices {
        if choice.choice == "old" {
            if let Some((old_content, old_edited)) = old_by_seq.get(&choice.seq) {
                // Kopier gammel tekst til nytt segment, bevar edited-flagg
                sqlx::query(
                    "UPDATE transcription_segments SET content = $1, edited = $2 WHERE node_id = $3 AND transcribed_at = $4 AND seq = $5",
                )
                .bind(old_content)
                .bind(*old_edited)
                .bind(req.node_id)
                .bind(new_ts)
                .bind(choice.seq)
                .execute(&mut *tx)
                .await
                .map_err(|e| {
                    tracing::error!(error = %e, "Feil ved oppdatering av segment");
                    internal_error("Databasefeil ved oppdatering")
                })?;
                kept_old += 1;
            }
        } else {
            kept_new += 1;
        }
    }

    // Slett alle gamle versjoner (ikke bare den valgte — rydd opp)
    sqlx::query(
        "DELETE FROM transcription_segments WHERE node_id = $1 AND transcribed_at < $2",
    )
    .bind(req.node_id)
    .bind(new_ts)
    .execute(&mut *tx)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "Feil ved sletting av gamle segmenter");
        internal_error("Databasefeil ved opprydding")
    })?;

    // Oppdater nodens content med den endelige transkripsjonen
    let final_segments: Vec<(String,)> = sqlx::query_as(
        "SELECT content FROM transcription_segments WHERE node_id = $1 AND transcribed_at = $2 ORDER BY seq",
    )
    .bind(req.node_id)
    .bind(new_ts)
    .fetch_all(&mut *tx)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "Feil ved henting av endelige segmenter");
        internal_error("Databasefeil")
    })?;

    let transcript_text: String = final_segments
        .iter()
        .map(|(c,)| c.trim())
        .collect::<Vec<_>>()
        .join(" ");

    sqlx::query("UPDATE nodes SET content = $1 WHERE id = $2")
        .bind(&transcript_text)
        .bind(req.node_id)
        .execute(&mut *tx)
        .await
        .map_err(|e| {
            tracing::error!(error = %e, "Feil ved oppdatering av node-innhold");
            internal_error("Databasefeil")
        })?;

    tx.commit().await.map_err(|e| {
        tracing::error!(error = %e, "Commit feilet");
        internal_error("Databasefeil ved commit")
    })?;

    tracing::info!(
        node_id = %req.node_id,
        kept_old = kept_old,
        kept_new = kept_new,
        "Re-transkripsjon løst"
    );

    Ok(Json(ResolveRetranscriptionResponse {
        resolved: true,
        kept_old,
        kept_new,
    }))
}

// =============================================================================
// POST /intentions/summarize — generer AI-sammendrag av kommunikasjonsnode
// =============================================================================

#[derive(Deserialize)]
pub struct SummarizeRequest {
    /// Kommunikasjonsnode-ID som skal oppsummeres.
    pub communication_id: Uuid,
}

#[derive(Serialize)]
pub struct SummarizeResponse {
    pub job_id: Uuid,
}

/// POST /intentions/summarize
///
/// Legger en `summarize_communication`-jobb i køen.
/// Sammendraget opprettes asynkront som en ny content-node
/// med summary-edge tilbake til kommunikasjonsnoden.
pub async fn summarize(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<SummarizeRequest>,
) -> Result<Json<SummarizeResponse>, (StatusCode, Json<ErrorResponse>)> {
    // Verifiser at kommunikasjonsnoden finnes og brukeren har tilgang
    let exists: bool = sqlx::query_scalar::<_, bool>(
        "SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1 AND node_kind = 'communication')",
    )
    .bind(req.communication_id)
    .fetch_one(&state.db)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "PG-feil ved kommunikasjonssjekk");
        internal_error("Databasefeil")
    })?;

    if !exists {
        return Err(bad_request("Kommunikasjonsnode finnes ikke"));
    }

    // Sjekk at brukeren er deltaker (owner eller member_of)
    let is_participant: bool = sqlx::query_scalar::<_, bool>(
        "SELECT EXISTS(SELECT 1 FROM edges WHERE source_id = $1 AND target_id = $2 AND edge_type IN ('owner', 'member_of'))",
    )
    .bind(user.node_id)
    .bind(req.communication_id)
    .fetch_one(&state.db)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "PG-feil ved deltagersjekk");
        internal_error("Databasefeil")
    })?;

    if !is_participant {
        return Err(forbidden("Ikke deltaker i samtalen"));
    }

    let payload = serde_json::json!({
        "communication_id": req.communication_id.to_string(),
        "requested_by": user.node_id.to_string()
    });

    let job_id = crate::jobs::enqueue(
        &state.db,
        "summarize_communication",
        payload,
        None,
        3, // Lav prioritet — ikke tidskritisk
    )
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "Kunne ikke legge oppsummerings-jobb i kø");
        internal_error("Kunne ikke starte oppsummering")
    })?;

    tracing::info!(
        job_id = %job_id,
        communication_id = %req.communication_id,
        user = %user.node_id,
        "Oppsummerings-jobb lagt i kø"
    );

    Ok(Json(SummarizeResponse { job_id }))
}

// =============================================================================
// POST /intentions/ai_process — AI-prosessering via AI Gateway
// =============================================================================

#[derive(Deserialize)]
pub struct AiProcessRequest {
    /// Kilde-noden som skal prosesseres.
    pub source_node_id: Uuid,
    /// AI-preset som definerer prompt og modellprofil.
    pub ai_preset_id: Uuid,
    /// Retning: "node_to_tool" (opprett ny node) eller "tool_to_node" (modifiser in-place).
    pub direction: String,
}

#[derive(Serialize)]
pub struct AiProcessResponse {
    pub job_id: Uuid,
}

/// POST /intentions/ai_process
///
/// Legger en `ai_process`-jobb i køen.
/// AI-prosesseringen skjer asynkront — kilde-content sendes til AI Gateway
/// med preset-prompt, og forbruk logges i ai_usage_log.
///
/// Direction-logikk (opprett ny node vs. oppdater eksisterende) implementeres
/// i oppgave 18.3.
///
/// Ref: docs/features/ai_verktoy.md § 6.1
pub async fn ai_process(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<AiProcessRequest>,
) -> Result<Json<AiProcessResponse>, (StatusCode, Json<ErrorResponse>)> {
    // Valider direction
    if req.direction != "node_to_tool" && req.direction != "tool_to_node" {
        return Err(bad_request(
            "direction må være 'node_to_tool' eller 'tool_to_node'",
        ));
    }

    // Sjekk at kilde-noden finnes
    let source_exists: bool = sqlx::query_scalar::<_, bool>(
        "SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1)",
    )
    .bind(req.source_node_id)
    .fetch_one(&state.db)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "PG-feil ved kilde-node-sjekk");
        internal_error("Databasefeil")
    })?;

    if !source_exists {
        return Err(bad_request("Kilde-node finnes ikke"));
    }

    // Sjekk at AI-preset finnes
    let preset_exists: bool = sqlx::query_scalar::<_, bool>(
        "SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1 AND node_kind = 'ai_preset')",
    )
    .bind(req.ai_preset_id)
    .fetch_one(&state.db)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "PG-feil ved preset-sjekk");
        internal_error("Databasefeil")
    })?;

    if !preset_exists {
        return Err(bad_request("AI-preset finnes ikke"));
    }

    // For tool_to_node-retning trengs skrivetilgang til kilde-noden
    if req.direction == "tool_to_node" {
        let can_modify = user_can_modify_node(&state.db, user.node_id, req.source_node_id)
            .await
            .map_err(|e| {
                tracing::error!(error = %e, "PG-feil ved tilgangssjekk");
                internal_error("Databasefeil")
            })?;

        if !can_modify {
            return Err(forbidden(
                "Ingen tilgang til å endre kilde-noden (tool_to_node krever skrivetilgang)",
            ));
        }
    }

    // Finn samlings-ID for kilde-noden (for prioritering)
    let collection_id = crate::resource_usage::find_collection_for_node(
        &state.db,
        req.source_node_id,
    )
    .await;

    let payload = serde_json::json!({
        "source_node_id": req.source_node_id.to_string(),
        "ai_preset_id": req.ai_preset_id.to_string(),
        "direction": req.direction,
        "requested_by": user.node_id.to_string()
    });

    let job_id = crate::jobs::enqueue(
        &state.db,
        "ai_process",
        payload,
        collection_id,
        5, // Medium prioritet
    )
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "Kunne ikke legge ai_process-jobb i kø");
        internal_error("Kunne ikke starte AI-prosessering")
    })?;

    tracing::info!(
        job_id = %job_id,
        source_node_id = %req.source_node_id,
        ai_preset_id = %req.ai_preset_id,
        direction = %req.direction,
        user = %user.node_id,
        "ai_process-jobb lagt i kø"
    );

    Ok(Json(AiProcessResponse { job_id }))
}

// =============================================================================
// POST /intentions/create_ai_preset — opprett egendefinert AI-preset
// =============================================================================

#[derive(Deserialize)]
pub struct CreateAiPresetRequest {
    /// Visningstittel for presetet.
    pub title: String,
    /// Systemprompt som brukes ved AI-prosessering.
    pub prompt: String,
    /// Standard retning: "node_to_tool", "tool_to_node" eller "both".
    pub default_direction: String,
    /// Ikon-nøkkel (f.eks. "sparkles", "pencil_square").
    pub icon: String,
    /// Hex-farge (#RRGGBB).
    pub color: String,
    /// Valgfri: samlings-ID å dele presetet med (oppretter shared_with-edge).
    pub share_with_collection_id: Option<Uuid>,
}

#[derive(Serialize)]
pub struct CreateAiPresetResponse {
    pub node_id: Uuid,
    /// Edge-ID for shared_with-edge (kun når share_with_collection_id er satt).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub shared_edge_id: Option<Uuid>,
}

/// POST /intentions/create_ai_preset
///
/// Oppretter en egendefinert AI-preset-node. Setter alltid `category: "custom"`
/// og `model_profile: "flash"`. Kun admin kan endre modellprofil etterpå
/// (via update_node).
///
/// Kan valgfritt dele med en samling via `share_with_collection_id`.
///
/// Ref: docs/features/ai_verktoy.md § "Fase C: Egendefinerte prompter"
pub async fn create_ai_preset(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<CreateAiPresetRequest>,
) -> Result<Json<CreateAiPresetResponse>, (StatusCode, Json<ErrorResponse>)> {
    // Valider input
    if req.title.trim().is_empty() {
        return Err(bad_request("Tittel kan ikke være tom"));
    }
    if req.prompt.trim().is_empty() {
        return Err(bad_request("Prompt kan ikke være tom"));
    }

    // Bygg metadata — alltid custom kategori og flash modellprofil
    let metadata = serde_json::json!({
        "prompt": req.prompt.trim(),
        "model_profile": "flash",
        "category": "custom",
        "default_direction": req.default_direction,
        "icon": req.icon,
        "color": req.color
    });

    // Valider metadata med eksisterende validator
    validate_ai_preset_metadata("ai_preset", &metadata).map_err(|e| bad_request(&e))?;

    // Valider share_with_collection_id hvis satt
    if let Some(col_id) = req.share_with_collection_id {
        let col_exists: bool = sqlx::query_scalar::<_, bool>(
            "SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1 AND node_kind = 'collection')",
        )
        .bind(col_id)
        .fetch_one(&state.db)
        .await
        .map_err(|e| {
            tracing::error!(error = %e, "PG-feil ved sjekk av samling");
            internal_error("Databasefeil")
        })?;

        if !col_exists {
            return Err(bad_request("Samlingen finnes ikke"));
        }
    }

    let node_id = Uuid::now_v7();

    // Skriv til PostgreSQL
    sqlx::query(
        r#"
        INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
        VALUES ($1, 'ai_preset', $2, '', 'discoverable'::visibility, $3, $4)
        "#,
    )
    .bind(node_id)
    .bind(req.title.trim())
    .bind(&metadata)
    .bind(user.node_id)
    .execute(&state.db)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "PG insert ai_preset feilet");
        internal_error("Kunne ikke opprette AI-preset")
    })?;

    // Opprett shared_with-edge hvis samlings-ID er satt
    let shared_edge_id = if let Some(col_id) = req.share_with_collection_id {
        let 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, 'shared_with', '{}', false, $4)
            "#,
        )
        .bind(edge_id)
        .bind(node_id)
        .bind(col_id)
        .bind(user.node_id)
        .execute(&state.db)
        .await
        .map_err(|e| {
            tracing::error!(error = %e, "PG insert shared_with-edge feilet");
            internal_error("Kunne ikke dele AI-preset med samling")
        })?;

        tracing::info!(
            preset_id = %node_id,
            collection_id = %col_id,
            edge_id = %edge_id,
            "AI-preset delt med samling"
        );

        Some(edge_id)
    } else {
        None
    };

    tracing::info!(
        node_id = %node_id,
        title = %req.title,
        user = %user.node_id,
        shared = ?req.share_with_collection_id,
        "Egendefinert AI-preset opprettet"
    );

    Ok(Json(CreateAiPresetResponse {
        node_id,
        shared_edge_id,
    }))
}

// =============================================================================
// POST /intentions/generate_tts — tekst-til-tale via ElevenLabs
// =============================================================================

#[derive(Deserialize)]
pub struct GenerateTtsRequest {
    /// Teksten som skal leses opp (maks 5000 tegn).
    pub text: String,
    /// Valgfri ElevenLabs voice_id. Faller tilbake på node-preferanse eller default.
    pub voice_id: Option<String>,
    /// Valgfri kilde-node som TTS-lyden knyttes til med has_media-edge.
    /// Hvis noden har metadata.voice_preference, brukes den som fallback for voice_id.
    pub source_node_id: Option<Uuid>,
    /// Språk (default: "no").
    pub language: Option<String>,
}

#[derive(Serialize)]
pub struct GenerateTtsResponse {
    pub job_id: Uuid,
}

/// POST /intentions/generate_tts
///
/// Legger en `tts_generate`-jobb i køen.
/// Lydfilen opprettes asynkront som en ny content-node i CAS.
pub async fn generate_tts(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<GenerateTtsRequest>,
) -> Result<Json<GenerateTtsResponse>, (StatusCode, Json<ErrorResponse>)> {
    if req.text.is_empty() {
        return Err(bad_request("Tekst kan ikke være tom"));
    }
    if req.text.len() > 5000 {
        return Err(bad_request("Tekst for lang (maks 5000 tegn)"));
    }

    // Hvis source_node_id er oppgitt, verifiser at noden finnes
    if let Some(source_id) = req.source_node_id {
        let exists: bool = sqlx::query_scalar::<_, bool>(
            "SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1)",
        )
        .bind(source_id)
        .fetch_one(&state.db)
        .await
        .map_err(|e| {
            tracing::error!(error = %e, "PG-feil ved node-sjekk");
            internal_error("Databasefeil")
        })?;

        if !exists {
            return Err(bad_request("Kildenode finnes ikke"));
        }
    }

    let mut payload = serde_json::json!({
        "text": req.text,
        "requested_by": user.node_id.to_string(),
        "language": req.language.as_deref().unwrap_or("no"),
    });

    if let Some(ref vid) = req.voice_id {
        payload["voice_id"] = serde_json::Value::String(vid.clone());
    }
    if let Some(source_id) = req.source_node_id {
        payload["source_node_id"] = serde_json::Value::String(source_id.to_string());
    }

    let job_id = crate::jobs::enqueue(
        &state.db,
        "tts_generate",
        payload,
        None,
        5, // Middels prioritet
    )
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "Kunne ikke legge TTS-jobb i kø");
        internal_error("Kunne ikke starte TTS-generering")
    })?;

    tracing::info!(
        job_id = %job_id,
        text_len = req.text.len(),
        user = %user.node_id,
        "TTS-jobb lagt i kø"
    );

    Ok(Json(GenerateTtsResponse { job_id }))
}

// =============================================================================
// LiveKit — Join/Leave Communication
// =============================================================================

#[derive(Deserialize)]
pub struct JoinCommunicationRequest {
    pub communication_id: Uuid,
    /// "publisher" (kan sende lyd) eller "subscriber" (bare lytte).
    /// Default: "publisher".
    pub role: Option<String>,
}

#[derive(Serialize)]
pub struct JoinCommunicationResponse {
    pub livekit_room_name: String,
    pub livekit_token: String,
    pub livekit_url: String,
    pub identity: String,
    pub participants: Vec<RoomParticipantInfo>,
}

#[derive(Serialize)]
pub struct RoomParticipantInfo {
    pub user_id: String,
    pub display_name: String,
    pub role: String,
}

/// POST /intentions/join_communication
///
/// Kobler en bruker til sanntidslyd i en kommunikasjonsnode.
/// Validerer tilgang (bruker må ha member_of/owner/host_of-edge),
/// genererer LiveKit-token, oppdaterer PG med live-status.
pub async fn join_communication(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<JoinCommunicationRequest>,
) -> Result<Json<JoinCommunicationResponse>, (StatusCode, Json<ErrorResponse>)> {
    let comm_id = req.communication_id;

    // Sjekk at kommunikasjonsnoden eksisterer og er riktig type
    let node_row = sqlx::query_as::<_, (String, String)>(
        "SELECT node_kind, title FROM nodes WHERE id = $1",
    )
    .bind(comm_id)
    .fetch_optional(&state.db)
    .await
    .map_err(|e| {
        tracing::error!("PG-feil ved nodesjekk: {e}");
        internal_error("Databasefeil ved validering")
    })?;

    let (node_kind, _title) = match node_row {
        Some(row) => row,
        None => return Err(bad_request("Kommunikasjonsnode finnes ikke")),
    };

    if node_kind != "communication" {
        return Err(bad_request(&format!(
            "Node er type '{node_kind}', ikke 'communication'"
        )));
    }

    // Sjekk at brukeren har tilgang (direkte eller via alias)
    let has_access = sqlx::query_scalar::<_, bool>(
        r#"
        SELECT EXISTS(
            -- Direkte edge: bruker → kommunikasjon
            SELECT 1 FROM edges
            WHERE source_id = $2 AND target_id = $1
              AND edge_type IN ('owner', 'member_of', 'host_of')
        ) OR EXISTS(
            -- Via alias: bruker --alias--> alias --member_of/etc--> kommunikasjon
            SELECT 1 FROM edges e_alias
            JOIN edges e_member ON e_member.source_id = e_alias.target_id
            WHERE e_alias.source_id = $2
              AND e_alias.edge_type = 'alias'
              AND e_alias.system = true
              AND e_member.target_id = $1
              AND e_member.edge_type IN ('owner', 'member_of', 'host_of')
        )
        "#,
    )
    .bind(comm_id)
    .bind(user.node_id)
    .fetch_one(&state.db)
    .await
    .map_err(|e| {
        tracing::error!("PG-feil ved tilgangssjekk: {e}");
        internal_error("Databasefeil ved tilgangssjekk")
    })?;

    if !has_access {
        return Err(forbidden("Ingen tilgang til denne kommunikasjonsnoden"));
    }

    // Resolve display name (alias or user title)
    let context_identity = resolve_context_identity(&state.db, user.node_id, comm_id)
        .await
        .map_err(|e| {
            tracing::error!("Kunne ikke resolve context identity: {e}");
            internal_error("Kunne ikke hente brukeridentitet")
        })?;

    let display_name = sqlx::query_scalar::<_, String>(
        "SELECT title FROM nodes WHERE id = $1",
    )
    .bind(context_identity)
    .fetch_optional(&state.db)
    .await
    .map_err(|e| {
        tracing::error!("PG-feil ved navnoppslag: {e}");
        internal_error("Databasefeil")
    })?
    .unwrap_or_else(|| "Ukjent".to_string());

    // Blokkér nye LiveKit-rom under vedlikehold (oppgave 15.2)
    if state.maintenance.is_active() {
        return Err(bad_request("Nye rom er blokkert — vedlikehold pågår"));
    }

    // Bestem rolle
    let role_str = req.role.as_deref().unwrap_or("publisher");
    let lk_role = match role_str {
        "subscriber" => livekit::RoomRole::Subscriber,
        _ => livekit::RoomRole::Publisher,
    };

    // Generer LiveKit-token
    let token_result = livekit::generate_token(
        comm_id,
        user.node_id,
        &display_name,
        lk_role,
        3600, // 1 time
    )
    .map_err(|e| {
        tracing::error!("LiveKit token-generering feilet: {e}");
        internal_error(&e)
    })?;

    let room_name = token_result.room_name.clone();

    // Oppdater kommunikasjonsnodens metadata med live_status (asynkront)
    let db = state.db.clone();
    let comm_id_clone = comm_id;
    let room_name_clone = room_name.clone();
    tokio::spawn(async move {
        // Les eksisterende metadata, legg til live_status
        let result = sqlx::query_scalar::<_, serde_json::Value>(
            "SELECT metadata FROM nodes WHERE id = $1",
        )
        .bind(comm_id_clone)
        .fetch_optional(&db)
        .await;

        if let Ok(Some(mut metadata)) = result {
            if let Some(obj) = metadata.as_object_mut() {
                obj.insert("live_status".into(), "active".into());
                obj.insert("livekit_room_name".into(), room_name_clone.clone().into());
            }

            if let Err(e) = sqlx::query(
                "UPDATE nodes SET metadata = $2 WHERE id = $1",
            )
            .bind(comm_id_clone)
            .bind(&metadata)
            .execute(&db)
            .await
            {
                tracing::error!("Kunne ikke oppdatere node metadata: {e}");
            }
        }
    });

    // Hent nåværende deltakere fra PG edges (for respons)
    let participants = sqlx::query_as::<_, (String, String)>(
        r#"
        SELECT e.source_id::text, COALESCE(n.title, 'Ukjent')
        FROM edges e
        LEFT JOIN nodes n ON n.id = e.source_id
        WHERE e.target_id = $1
          AND e.edge_type IN ('owner', 'member_of', 'host_of')
        "#,
    )
    .bind(comm_id)
    .fetch_all(&state.db)
    .await
    .map_err(|e| {
        tracing::error!("PG-feil ved deltakerhenting: {e}");
        internal_error("Databasefeil")
    })?
    .into_iter()
    .map(|(uid, name)| RoomParticipantInfo {
        user_id: uid,
        display_name: name,
        role: "publisher".to_string(),
    })
    .collect();

    let livekit_url = std::env::var("LIVEKIT_WS_URL")
        .unwrap_or_else(|_| {
            // Fallback: bruk domene med wss
            "wss://ws.synops.no/livekit".to_string()
        });

    tracing::info!(
        communication_id = %comm_id,
        user = %user.node_id,
        room = %room_name,
        role = %role_str,
        "Bruker koblet til LiveKit-rom"
    );

    // Logg LiveKit-ressursforbruk (registrering av deltaker-join)
    let lk_collection_id = crate::resource_usage::find_collection_for_node(&state.db, comm_id).await;
    if let Err(e) = crate::resource_usage::log(
        &state.db,
        comm_id,
        Some(user.node_id),
        lk_collection_id,
        "livekit",
        serde_json::json!({
            "room_id": room_name,
            "participant_minutes": 0,
            "tracks": 0,
            "event": "join"
        }),
    )
    .await
    {
        tracing::warn!(error = %e, "Kunne ikke logge LiveKit-ressursforbruk");
    }

    Ok(Json(JoinCommunicationResponse {
        livekit_room_name: room_name,
        livekit_token: token_result.token,
        livekit_url,
        identity: token_result.identity,
        participants,
    }))
}

#[derive(Deserialize)]
pub struct LeaveCommunicationRequest {
    pub communication_id: Uuid,
}

#[derive(Serialize)]
pub struct LeaveCommunicationResponse {
    pub status: String,
}

/// POST /intentions/leave_communication
///
/// Fjerner brukerens sanntidslyd-tilkobling fra en kommunikasjonsnode.
pub async fn leave_communication(
    State(_state): State<AppState>,
    user: AuthUser,
    Json(req): Json<LeaveCommunicationRequest>,
) -> Result<Json<LeaveCommunicationResponse>, (StatusCode, Json<ErrorResponse>)> {
    let comm_id = req.communication_id;
    let room_name = format!("communication_{comm_id}");

    tracing::info!(
        communication_id = %comm_id,
        user = %user.node_id,
        room = %room_name,
        "Bruker forlot LiveKit-rom"
    );

    Ok(Json(LeaveCommunicationResponse {
        status: "left".to_string(),
    }))
}

#[derive(Deserialize)]
pub struct CloseCommunicationRequest {
    pub communication_id: Uuid,
}

#[derive(Serialize)]
pub struct CloseCommunicationResponse {
    pub status: String,
}

/// POST /intentions/close_communication
///
/// Stenger et sanntidsrom. Krever owner/admin-tilgang.
/// Oppdaterer metadata til "ended".
pub async fn close_communication(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<CloseCommunicationRequest>,
) -> Result<Json<CloseCommunicationResponse>, (StatusCode, Json<ErrorResponse>)> {
    let comm_id = req.communication_id;

    // Bare owner/admin kan stenge
    if !user_can_modify_node(&state.db, user.node_id, comm_id)
        .await
        .map_err(|e| {
            tracing::error!("PG-feil ved tilgangssjekk: {e}");
            internal_error("Databasefeil")
        })?
    {
        return Err(forbidden("Bare eier kan stenge kommunikasjonsrom"));
    }

    // Oppdater metadata i PG (NOTIFY-trigger sender sanntidsoppdatering)
    let db = state.db.clone();
    tokio::spawn(async move {
        let result = sqlx::query_scalar::<_, serde_json::Value>(
            "SELECT metadata FROM nodes WHERE id = $1",
        )
        .bind(comm_id)
        .fetch_optional(&db)
        .await;

        if let Ok(Some(mut metadata)) = result {
            if let Some(obj) = metadata.as_object_mut() {
                obj.insert("live_status".into(), "ended".into());
                obj.insert(
                    "ended_at".into(),
                    chrono::Utc::now().to_rfc3339().into(),
                );
            }

            let _ = sqlx::query("UPDATE nodes SET metadata = $2 WHERE id = $1")
                .bind(comm_id)
                .bind(&metadata)
                .execute(&db)
                .await;
        }
    });

    tracing::info!(
        communication_id = %comm_id,
        user = %user.node_id,
        "Kommunikasjonsrom stengt"
    );

    Ok(Json(CloseCommunicationResponse {
        status: "closed".to_string(),
    }))
}

// =============================================================================
// Lydstudio
// =============================================================================

#[derive(Deserialize)]
pub struct AudioAnalyzeRequest {
    pub cas_hash: String,
    pub silence_threshold_db: Option<f32>,
    pub silence_min_duration_ms: Option<u32>,
}

/// POST /intentions/audio_analyze
///
/// Synkron analyse av en lydfil: loudness (LUFS), silence-regioner, og metadata.
/// Brukes av studioet for å vise nåværende tilstand før redigering.
pub async fn audio_analyze(
    State(state): State<AppState>,
    _user: AuthUser,
    Json(req): Json<AudioAnalyzeRequest>,
) -> Result<Json<crate::audio::AnalyzeResult>, (StatusCode, Json<ErrorResponse>)> {
    let cas = &state.cas;

    if !cas.exists(&req.cas_hash) {
        return Err(bad_request("Filen finnes ikke i CAS"));
    }

    let info = crate::audio::get_audio_info(cas, &req.cas_hash)
        .await
        .map_err(|e| internal_error(&e))?;

    let loudness = crate::audio::analyze_loudness(cas, &req.cas_hash)
        .await
        .map_err(|e| internal_error(&e))?;

    let threshold = req.silence_threshold_db.unwrap_or(-30.0);
    let min_dur = req.silence_min_duration_ms.unwrap_or(500);

    let silence_regions = crate::audio::detect_silence(cas, &req.cas_hash, threshold, min_dur)
        .await
        .map_err(|e| internal_error(&e))?;

    Ok(Json(crate::audio::AnalyzeResult {
        loudness,
        silence_regions,
        info,
    }))
}

#[derive(Deserialize)]
pub struct AudioProcessRequest {
    pub media_node_id: Uuid,
    pub edl: crate::audio::EdlDocument,
    pub output_format: Option<String>,
}

#[derive(Serialize)]
pub struct AudioProcessResponse {
    pub job_id: Uuid,
}

/// POST /intentions/audio_process
///
/// Køer en audio-prosessering-jobb. Resultatet blir en ny medienode
/// med derived_from-edge til originalen.
pub async fn audio_process(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<AudioProcessRequest>,
) -> Result<Json<AudioProcessResponse>, (StatusCode, Json<ErrorResponse>)> {
    // Sjekk at medienoden eksisterer
    if !node_exists(&state.db, req.media_node_id).await.map_err(|e| {
        tracing::error!("DB-feil: {e}");
        internal_error("Databasefeil")
    })? {
        return Err(bad_request("media_node_id finnes ikke"));
    }

    // Sjekk at kildefilen finnes i CAS
    if !state.cas.exists(&req.edl.source_hash) {
        return Err(bad_request("source_hash finnes ikke i CAS"));
    }

    let output_format = req.output_format.unwrap_or_else(|| "mp3".to_string());

    let payload = serde_json::json!({
        "media_node_id": req.media_node_id.to_string(),
        "edl": req.edl,
        "output_format": output_format,
        "requested_by": user.node_id.to_string(),
    });

    let job_id = crate::jobs::enqueue(&state.db, "audio_process", payload, None, 5)
        .await
        .map_err(|e| {
            tracing::error!("Kunne ikke køe audio_process-jobb: {e}");
            internal_error("Kunne ikke køe jobb")
        })?;

    Ok(Json(AudioProcessResponse { job_id }))
}

#[derive(Deserialize)]
pub struct AudioInfoQuery {
    pub hash: String,
}

/// GET /query/audio_info?hash=...
///
/// Hent metadata om en lydfil (varighet, sample rate, kanaler, codec).
pub async fn audio_info(
    State(state): State<AppState>,
    _user: AuthUser,
    axum::extract::Query(query): axum::extract::Query<AudioInfoQuery>,
) -> Result<Json<crate::audio::AudioInfo>, (StatusCode, Json<ErrorResponse>)> {
    if !state.cas.exists(&query.hash) {
        return Err(bad_request("Filen finnes ikke i CAS"));
    }

    let info = crate::audio::get_audio_info(&state.cas, &query.hash)
        .await
        .map_err(|e| internal_error(&e))?;

    Ok(Json(info))
}

// =============================================================================
// Systemvarsler (oppgave 15.1)
// =============================================================================

/// Gyldige varslingstyper for system_announcement-noder.
const VALID_ANNOUNCEMENT_TYPES: &[&str] = &["info", "warning", "critical"];

#[derive(Deserialize)]
pub struct CreateAnnouncementRequest {
    /// Tittel på varselet.
    pub title: String,
    /// Innhold/meldingstekst.
    pub content: String,
    /// Type varsel: info, warning, critical.
    pub announcement_type: String,
    /// Tidspunkt varselet gjelder (f.eks. vedlikeholdstidspunkt). Valgfritt.
    pub scheduled_at: Option<String>,
    /// Når varselet automatisk utløper (ISO 8601). Valgfritt.
    pub expires_at: Option<String>,
    /// Om nye sesjoner skal blokkeres (vedlikeholdsmodus). Default: false.
    pub blocks_new_sessions: Option<bool>,
}

#[derive(Serialize)]
pub struct CreateAnnouncementResponse {
    pub node_id: Uuid,
}

/// POST /intentions/create_announcement
///
/// Oppretter en systemvarslingsnode med `visibility: open` slik at alle
/// aktive klienter ser varselet via WebSocket umiddelbart.
///
/// Kun autentiserte brukere kan opprette varsler (MVP — full admin-sjekk
/// legges til når admin-rollesystemet er på plass).
///
/// Ref: docs/concepts/adminpanelet.md § "Systemvarsler og vedlikeholdsmodus"
pub async fn create_announcement(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<CreateAnnouncementRequest>,
) -> Result<Json<CreateAnnouncementResponse>, (StatusCode, Json<ErrorResponse>)> {
    // -- Valider announcement_type --
    if !VALID_ANNOUNCEMENT_TYPES.contains(&req.announcement_type.as_str()) {
        return Err(bad_request(&format!(
            "Ugyldig announcement_type: '{}'. Gyldige verdier: {:?}",
            req.announcement_type, VALID_ANNOUNCEMENT_TYPES
        )));
    }

    if req.title.trim().is_empty() {
        return Err(bad_request("title kan ikke være tom"));
    }

    // -- Valider datoer hvis satt --
    if let Some(ref s) = req.scheduled_at {
        chrono::DateTime::parse_from_rfc3339(s)
            .map_err(|_| bad_request("scheduled_at må være gyldig ISO 8601 (RFC 3339)"))?;
    }
    if let Some(ref s) = req.expires_at {
        chrono::DateTime::parse_from_rfc3339(s)
            .map_err(|_| bad_request("expires_at må være gyldig ISO 8601 (RFC 3339)"))?;
    }

    // -- Bygg metadata --
    let metadata = serde_json::json!({
        "announcement_type": req.announcement_type,
        "scheduled_at": req.scheduled_at,
        "expires_at": req.expires_at,
        "blocks_new_sessions": req.blocks_new_sessions.unwrap_or(false),
    });

    let node_id = Uuid::now_v7();

    // -- Skriv til PostgreSQL (NOTIFY-trigger sender sanntidsoppdatering) --
    sqlx::query(
        r#"INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
           VALUES ($1, 'system_announcement', $2, $3, 'open'::visibility, $4, $5)"#,
    )
    .bind(node_id)
    .bind(&req.title)
    .bind(&req.content)
    .bind(&metadata)
    .bind(user.node_id)
    .execute(&state.db)
    .await
    .map_err(|e| {
        tracing::error!("PG-feil ved opprettelse av systemvarsel: {e}");
        internal_error("Databasefeil ved opprettelse av systemvarsel")
    })?;

    tracing::info!(
        node_id = %node_id,
        announcement_type = %req.announcement_type,
        created_by = %user.node_id,
        "Systemvarsel opprettet"
    );

    Ok(Json(CreateAnnouncementResponse { node_id }))
}

#[derive(Deserialize)]
pub struct ExpireAnnouncementRequest {
    /// ID-en til varslingsnoden som skal utløpe/fjernes.
    pub node_id: Uuid,
}

#[derive(Serialize)]
pub struct ExpireAnnouncementResponse {
    pub expired: bool,
}

/// POST /intentions/expire_announcement
///
/// Fjerner (sletter) en systemvarslingsnode fra PG.
/// WebSocket NOTIFY sørger for umiddelbar fjerning fra alle klienter.
///
/// Kun eier (created_by) eller admin kan fjerne varsler.
pub async fn expire_announcement(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<ExpireAnnouncementRequest>,
) -> Result<Json<ExpireAnnouncementResponse>, (StatusCode, Json<ErrorResponse>)> {
    // -- Sjekk at noden eksisterer og er en system_announcement --
    let node = sqlx::query_as::<_, NodeRow>(
        "SELECT node_kind, title, content, visibility, metadata FROM nodes WHERE id = $1",
    )
    .bind(req.node_id)
    .fetch_optional(&state.db)
    .await
    .map_err(|e| {
        tracing::error!("PG-feil ved oppslag: {e}");
        internal_error("Databasefeil")
    })?
    .ok_or_else(|| bad_request("Noden finnes ikke"))?;

    if node.node_kind != "system_announcement" {
        return Err(bad_request("Noden er ikke en systemvarslingsnode"));
    }

    // -- Tilgangskontroll --
    let can_modify = user_can_modify_node(&state.db, user.node_id, req.node_id)
        .await
        .map_err(|e| {
            tracing::error!("PG-feil ved tilgangssjekk: {e}");
            internal_error("Databasefeil ved tilgangssjekk")
        })?;

    if !can_modify {
        return Err(forbidden("Kun eier kan fjerne systemvarsler"));
    }

    // -- Slett fra PG (NOTIFY-trigger sender sanntidsoppdatering) --
    sqlx::query("DELETE FROM nodes WHERE id = $1")
        .bind(req.node_id)
        .execute(&state.db)
        .await
        .map_err(|e| {
            tracing::error!("PG-feil ved sletting av varsel: {e}");
            internal_error("Databasefeil ved sletting av varsel")
        })?;

    tracing::info!(node_id = %req.node_id, "Systemvarsel slettet");

    Ok(Json(ExpireAnnouncementResponse { expired: true }))
}

// =============================================================================
// Vedlikeholdsmodus (oppgave 15.2)
// =============================================================================

#[derive(Deserialize)]
pub struct InitiateMaintenanceRequest {
    /// Vedlikeholdstidspunkt (ISO 8601 / RFC 3339).
    pub scheduled_at: String,
}

#[derive(Serialize)]
pub struct InitiateMaintenanceResponse {
    pub announcement_node_id: Uuid,
    pub scheduled_at: String,
}

/// POST /intentions/initiate_maintenance
///
/// Starter nedtellingen til vedlikehold. Oppretter et critical-varsel
/// som vises for alle klienter, og starter bakgrunnskoordinatoren som
/// blokkerer nye jobber/LiveKit-rom og til slutt restarter prosessen.
///
/// Kall GET /admin/maintenance_status først for å se aktive sesjoner.
pub async fn initiate_maintenance(
    State(state): State<AppState>,
    admin: AdminUser,
    Json(req): Json<InitiateMaintenanceRequest>,
) -> Result<Json<InitiateMaintenanceResponse>, (StatusCode, Json<ErrorResponse>)> {
    let scheduled_at = chrono::DateTime::parse_from_rfc3339(&req.scheduled_at)
        .map_err(|_| bad_request("scheduled_at må være gyldig ISO 8601 (RFC 3339)"))?
        .with_timezone(&chrono::Utc);

    if scheduled_at < chrono::Utc::now() {
        return Err(bad_request("scheduled_at kan ikke være i fortiden"));
    }

    let user_id = admin.node_id;

    let announcement_id = state
        .maintenance
        .initiate(&state.db, scheduled_at, user_id)
        .await
        .map_err(|e| bad_request(&e))?;

    Ok(Json(InitiateMaintenanceResponse {
        announcement_node_id: announcement_id,
        scheduled_at: scheduled_at.to_rfc3339(),
    }))
}

#[derive(Serialize)]
pub struct CancelMaintenanceResponse {
    pub cancelled: bool,
}

/// POST /intentions/cancel_maintenance
///
/// Avbryter planlagt vedlikehold. Fjerner varselet og stopper
/// nedtellingstasken.
pub async fn cancel_maintenance(
    State(state): State<AppState>,
    _admin: AdminUser,
) -> Result<Json<CancelMaintenanceResponse>, (StatusCode, Json<ErrorResponse>)> {
    state
        .maintenance
        .cancel(&state.db)
        .await
        .map_err(|e| bad_request(&e))?;

    Ok(Json(CancelMaintenanceResponse { cancelled: true }))
}

/// GET /admin/maintenance_status
///
/// Returnerer vedlikeholdsstatus inkludert kjørende jobber.
/// Brukes av admin-panelet for å vise aktive sesjoner før bekreftelse.
pub async fn maintenance_status(
    State(state): State<AppState>,
    _admin: AdminUser,
) -> Result<Json<crate::maintenance::MaintenanceStatus>, (StatusCode, Json<ErrorResponse>)> {
    let status = state
        .maintenance
        .status(&state.db)
        .await
        .map_err(|e| internal_error(&format!("Feil ved henting av vedlikeholdsstatus: {e}")))?;

    Ok(Json(status))
}

// =============================================================================
// Jobbkø-oversikt (oppgave 15.3)
// =============================================================================

/// GET /admin/jobs
///
/// Hent jobbliste med valgfrie filtre: status, type, collection_id.
/// Query-params: ?status=error&type=whisper_transcribe&collection_id=...&limit=50&offset=0
pub async fn list_jobs(
    State(state): State<AppState>,
    _admin: AdminUser,
    axum::extract::Query(params): axum::extract::Query<ListJobsParams>,
) -> Result<Json<ListJobsResponse>, (StatusCode, Json<ErrorResponse>)> {
    let limit = params.limit.unwrap_or(50).min(200);
    let offset = params.offset.unwrap_or(0);

    let jobs = crate::jobs::list_jobs(
        &state.db,
        params.status.as_deref(),
        params.r#type.as_deref(),
        params.collection_id,
        limit,
        offset,
    )
    .await
    .map_err(|e| internal_error(&format!("Feil ved henting av jobber: {e}")))?;

    let counts = crate::jobs::count_by_status(&state.db)
        .await
        .map_err(|e| internal_error(&format!("Feil ved telling av jobber: {e}")))?;

    let job_types = crate::jobs::distinct_job_types(&state.db)
        .await
        .map_err(|e| internal_error(&format!("Feil ved henting av jobbtyper: {e}")))?;

    Ok(Json(ListJobsResponse { jobs, counts, job_types }))
}

#[derive(Deserialize)]
pub struct ListJobsParams {
    pub status: Option<String>,
    pub r#type: Option<String>,
    pub collection_id: Option<Uuid>,
    pub limit: Option<i64>,
    pub offset: Option<i64>,
}

#[derive(Serialize)]
pub struct ListJobsResponse {
    pub jobs: Vec<crate::jobs::JobDetail>,
    pub counts: Vec<crate::jobs::JobCountByStatus>,
    pub job_types: Vec<String>,
}

/// POST /intentions/retry_job
///
/// Sett en feilet jobb tilbake til 'pending' for nytt forsøk.
pub async fn retry_job(
    State(state): State<AppState>,
    _admin: AdminUser,
    Json(req): Json<JobIdRequest>,
) -> Result<Json<JobActionResponse>, (StatusCode, Json<ErrorResponse>)> {
    let retried = crate::jobs::retry_job(&state.db, req.job_id)
        .await
        .map_err(|e| internal_error(&format!("Feil ved retry: {e}")))?;

    if !retried {
        return Err(bad_request("Jobben finnes ikke eller har feil status for retry"));
    }

    tracing::info!(job_id = %req.job_id, user = %_admin.node_id, "Admin: jobb restartet");
    Ok(Json(JobActionResponse { success: true }))
}

/// POST /intentions/cancel_job
///
/// Avbryt en ventende jobb.
pub async fn cancel_job(
    State(state): State<AppState>,
    _admin: AdminUser,
    Json(req): Json<JobIdRequest>,
) -> Result<Json<JobActionResponse>, (StatusCode, Json<ErrorResponse>)> {
    let cancelled = crate::jobs::cancel_job(&state.db, req.job_id)
        .await
        .map_err(|e| internal_error(&format!("Feil ved avbryt: {e}")))?;

    if !cancelled {
        return Err(bad_request("Jobben finnes ikke eller har feil status for avbryt"));
    }

    tracing::info!(job_id = %req.job_id, user = %_admin.node_id, "Admin: jobb avbrutt");
    Ok(Json(JobActionResponse { success: true }))
}

#[derive(Deserialize)]
pub struct JobIdRequest {
    pub job_id: Uuid,
}

#[derive(Serialize)]
pub struct JobActionResponse {
    pub success: bool,
}

// =============================================================================
// Ressursstyring: admin-endepunkter (oppgave 15.5)
// =============================================================================

/// GET /admin/resources — samlet ressursstatus.
pub async fn resource_status(
    State(state): State<AppState>,
    _admin: AdminUser,
) -> Result<Json<crate::resources::ResourceStatus>, (StatusCode, Json<ErrorResponse>)> {
    let cas_root = std::env::var("CAS_ROOT")
        .unwrap_or_else(|_| "/srv/synops/media/cas".to_string());

    let disk = crate::resources::check_disk_usage(&cas_root)
        .unwrap_or_else(|_| crate::resources::check_disk_usage("/").unwrap_or(
            crate::resources::DiskStatus {
                mount_point: "/".to_string(),
                total_bytes: 0,
                used_bytes: 0,
                available_bytes: 0,
                usage_percent: 0.0,
                alert_level: None,
            }
        ));

    let livekit_active = crate::resources::has_active_livekit_rooms(&state.db).await;
    let total_weight = crate::resources::total_running_weight(&state.db, &state.priority_rules).await;
    let rules = state.priority_rules.all().await;
    let running = crate::resources::running_jobs_by_type(&state.db)
        .await
        .map_err(|e| internal_error(&format!("DB-feil: {e}")))?;

    Ok(Json(crate::resources::ResourceStatus {
        disk,
        livekit_active,
        total_running_weight: total_weight,
        max_weight: 8, // MAX_TOTAL_WEIGHT fra jobs.rs
        priority_rules: rules,
        running_jobs_by_type: running,
    }))
}

/// GET /admin/resources/disk — disk-status med historikk.
pub async fn resource_disk(
    State(state): State<AppState>,
    _admin: AdminUser,
) -> Result<Json<DiskOverview>, (StatusCode, Json<ErrorResponse>)> {
    let current = crate::resources::latest_disk_status(&state.db)
        .await
        .map_err(|e| internal_error(&format!("DB-feil: {e}")))?;
    let history = crate::resources::disk_status_history(&state.db, 60)
        .await
        .map_err(|e| internal_error(&format!("DB-feil: {e}")))?;

    Ok(Json(DiskOverview { current, history }))
}

#[derive(Serialize)]
pub struct DiskOverview {
    pub current: Option<crate::resources::DiskStatus>,
    pub history: Vec<crate::resources::DiskStatusHistoryRow>,
}

/// POST /admin/resources/update_rule — oppdater en prioritetsregel.
pub async fn update_priority_rule(
    State(state): State<AppState>,
    _admin: AdminUser,
    Json(req): Json<UpdatePriorityRuleRequest>,
) -> Result<Json<JobActionResponse>, (StatusCode, Json<ErrorResponse>)> {
    sqlx::query(
        r#"INSERT INTO job_priority_rules (job_type, base_priority, livekit_priority_adj, cpu_weight, max_concurrent, timeout_seconds, block_during_livekit)
           VALUES ($1, $2, $3, $4, $5, $6, $7)
           ON CONFLICT (job_type) DO UPDATE SET
               base_priority = EXCLUDED.base_priority,
               livekit_priority_adj = EXCLUDED.livekit_priority_adj,
               cpu_weight = EXCLUDED.cpu_weight,
               max_concurrent = EXCLUDED.max_concurrent,
               timeout_seconds = EXCLUDED.timeout_seconds,
               block_during_livekit = EXCLUDED.block_during_livekit"#,
    )
    .bind(&req.job_type)
    .bind(req.base_priority)
    .bind(req.livekit_priority_adj)
    .bind(req.cpu_weight)
    .bind(req.max_concurrent)
    .bind(req.timeout_seconds)
    .bind(req.block_during_livekit)
    .execute(&state.db)
    .await
    .map_err(|e| internal_error(&format!("DB-feil: {e}")))?;

    // Oppdater cache
    if let Err(e) = state.priority_rules.refresh(&state.db).await {
        tracing::warn!(error = %e, "Kunne ikke refreshe prioritetsregler-cache");
    }

    tracing::info!(
        job_type = %req.job_type,
        user = %_admin.node_id,
        "Admin: prioritetsregel oppdatert"
    );

    Ok(Json(JobActionResponse { success: true }))
}

#[derive(Deserialize)]
pub struct UpdatePriorityRuleRequest {
    pub job_type: String,
    pub base_priority: i16,
    pub livekit_priority_adj: i16,
    pub cpu_weight: i16,
    pub max_concurrent: i16,
    pub timeout_seconds: i32,
    pub block_during_livekit: bool,
}

// =============================================================================
// Orkestrering: kompilering og testkjøring (oppgave 24.6)
// =============================================================================

#[derive(Deserialize)]
pub struct CompileScriptRequest {
    pub script: String,
}

/// Kompiler et orkestreringsscript og returner diagnostikk + kompilert resultat.
/// Brukes av frontend-editoren for sanntids kompileringsfeedback.
pub async fn compile_script(
    State(state): State<AppState>,
    _user: AuthUser,
    Json(req): Json<CompileScriptRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
    use crate::script_compiler;

    let result = script_compiler::compile_script(&state.db, &req.script)
        .await
        .map_err(|e| bad_request(&e))?;

    // Serialiser CompileResult til JSON
    let json = serde_json::to_value(&result)
        .map_err(|e| internal_error(&format!("Serialiseringsfeil: {e}")))?;

    Ok(Json(json))
}

#[derive(Deserialize)]
pub struct TestOrchestrationRequest {
    pub orchestration_id: Uuid,
}

#[derive(Serialize)]
pub struct TestOrchestrationResponse {
    pub job_id: String,
}

/// Kjør en orkestrering manuelt (testkjøring).
/// Oppretter en "orchestrate"-jobb med trigger_event = "manual".
pub async fn test_orchestration(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<TestOrchestrationRequest>,
) -> Result<Json<TestOrchestrationResponse>, (StatusCode, Json<ErrorResponse>)> {
    // Verifiser at noden er en orchestration-node
    let node = sqlx::query_as::<_, (String, Option<String>)>(
        "SELECT node_kind, content FROM nodes WHERE id = $1",
    )
    .bind(req.orchestration_id)
    .fetch_optional(&state.db)
    .await
    .map_err(|e| internal_error(&format!("DB-feil: {e}")))?
    .ok_or_else(|| bad_request("Orkestreringsnode ikke funnet"))?;

    if node.0 != "orchestration" {
        return Err(bad_request("Noden er ikke en orchestration-node"));
    }

    // Opprett en jobb i køen
    let job_id = sqlx::query_scalar::<_, Uuid>(
        r#"
        INSERT INTO job_queue (job_type, collection_node_id, payload, status, priority)
        VALUES ('orchestrate', NULL, $1, 'pending', 5)
        RETURNING id
        "#,
    )
    .bind(serde_json::json!({
        "orchestration_id": req.orchestration_id.to_string(),
        "trigger_event": "manual",
        "trigger_context": {},
        "test_run": true,
        "initiated_by": user.node_id.to_string(),
    }))
    .fetch_one(&state.db)
    .await
    .map_err(|e| internal_error(&format!("Kunne ikke opprette testjobb: {e}")))?;

    tracing::info!(
        orchestration_id = %req.orchestration_id,
        job_id = %job_id,
        user = %user.node_id,
        "Manuell testkjøring av orkestrering startet"
    );

    Ok(Json(TestOrchestrationResponse {
        job_id: job_id.to_string(),
    }))
}

#[derive(Deserialize)]
pub struct OrchestrationLogParams {
    pub orchestration_id: Uuid,
    pub limit: Option<i64>,
}

#[derive(Serialize)]
pub struct OrchestrationLogEntry {
    pub id: String,
    pub job_id: Option<String>,
    pub step_number: i16,
    pub tool_binary: String,
    pub args: serde_json::Value,
    pub is_fallback: bool,
    pub status: String,
    pub exit_code: Option<i16>,
    pub error_msg: Option<String>,
    pub duration_ms: Option<i32>,
    pub created_at: String,
}

#[derive(Serialize)]
pub struct OrchestrationLogResponse {
    pub entries: Vec<OrchestrationLogEntry>,
}

/// Hent kjørehistorikk for en orkestrering.
pub async fn orchestration_log(
    State(state): State<AppState>,
    _user: AuthUser,
    axum::extract::Query(params): axum::extract::Query<OrchestrationLogParams>,
) -> Result<Json<OrchestrationLogResponse>, (StatusCode, Json<ErrorResponse>)> {
    let limit = params.limit.unwrap_or(50).min(200);

    let rows = sqlx::query_as::<_, (
        Uuid,
        Option<Uuid>,
        i16,
        String,
        serde_json::Value,
        bool,
        String,
        Option<i16>,
        Option<String>,
        Option<i32>,
        chrono::DateTime<chrono::Utc>,
    )>(
        r#"
        SELECT id, job_id, step_number, tool_binary, args, is_fallback,
               status, exit_code, error_msg, duration_ms, created_at
        FROM orchestration_log
        WHERE orchestration_id = $1
        ORDER BY created_at DESC
        LIMIT $2
        "#,
    )
    .bind(params.orchestration_id)
    .bind(limit)
    .fetch_all(&state.db)
    .await
    .map_err(|e| internal_error(&format!("DB-feil: {e}")))?;

    let entries = rows
        .into_iter()
        .map(|r| OrchestrationLogEntry {
            id: r.0.to_string(),
            job_id: r.1.map(|u| u.to_string()),
            step_number: r.2,
            tool_binary: r.3,
            args: r.4,
            is_fallback: r.5,
            status: r.6,
            exit_code: r.7,
            error_msg: r.8,
            duration_ms: r.9,
            created_at: r.10.to_rfc3339(),
        })
        .collect();

    Ok(Json(OrchestrationLogResponse { entries }))
}

// =============================================================================
// AI-assistert script-generering (oppgave 24.7)
// =============================================================================

#[derive(Deserialize)]
pub struct AiSuggestScriptRequest {
    /// Fritekst-beskrivelse av ønsket orkestrering
    pub description: String,
    /// Trigger-event (valgfri, f.eks. "communication.ended")
    pub trigger_event: Option<String>,
    /// Trigger-betingelser som JSON (valgfri)
    pub trigger_conditions: Option<serde_json::Value>,
    /// Eventually-modus: lagre som work_item i stedet for synkront LLM-kall
    #[serde(default)]
    pub eventually: bool,
    /// Samlings-ID å knytte til (valgfri)
    pub collection_id: Option<Uuid>,
}

#[derive(Serialize)]
pub struct AiSuggestScriptResponse {
    pub status: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub script: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub compile_result: Option<serde_json::Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub work_item_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
}

/// AI-assistert generering av orkestreringsscript.
///
/// Kaller `synops-ai` CLI-verktøyet for å generere et script fra
/// fritekst-beskrivelse, og validerer resultatet via script-kompilatoren.
/// I eventually-modus lagres forespørselen som work_item for Claude Code.
///
/// Ref: docs/concepts/orkestrering.md § "Nivå 2: AI-assistert oppretting"
pub async fn ai_suggest_script(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<AiSuggestScriptRequest>,
) -> Result<Json<AiSuggestScriptResponse>, (StatusCode, Json<ErrorResponse>)> {
    use crate::cli_dispatch;
    use crate::script_compiler;

    if req.description.trim().is_empty() {
        return Err(bad_request("description kan ikke være tom"));
    }

    // Eventually-modus: kall synops-ai --eventually
    if req.eventually {
        let mut cmd = tokio::process::Command::new("synops-ai");
        cmd.arg("--description").arg(&req.description);
        cmd.arg("--eventually");
        cmd.arg("--requested-by").arg(user.node_id.to_string());

        if let Some(ref event) = req.trigger_event {
            cmd.arg("--trigger-event").arg(event);
        }
        if let Some(ref conditions) = req.trigger_conditions {
            cmd.arg("--trigger-conditions").arg(conditions.to_string());
        }
        if let Some(ref coll_id) = req.collection_id {
            cmd.arg("--collection-id").arg(coll_id.to_string());
        }

        cli_dispatch::set_database_url(&mut cmd)
            .map_err(|e| internal_error(&e))?;
        cli_dispatch::forward_env(&mut cmd, "AI_GATEWAY_URL");
        cli_dispatch::forward_env(&mut cmd, "LITELLM_MASTER_KEY");
        cli_dispatch::forward_env(&mut cmd, "AI_SCRIPT_MODEL");

        let result = cli_dispatch::run_cli_tool("synops-ai", &mut cmd)
            .await
            .map_err(|e| internal_error(&format!("synops-ai feilet: {e}")))?;

        let work_item_id = result.get("work_item_id")
            .and_then(|v| v.as_str())
            .map(|s| s.to_string());

        return Ok(Json(AiSuggestScriptResponse {
            status: "deferred".to_string(),
            script: None,
            compile_result: None,
            work_item_id,
            message: Some("Forespørselen er lagret. Scriptet genereres i bakgrunnen.".to_string()),
        }));
    }

    // Synkron modus: kall synops-ai for generering
    let mut cmd = tokio::process::Command::new("synops-ai");
    cmd.arg("--description").arg(&req.description);

    if let Some(ref event) = req.trigger_event {
        cmd.arg("--trigger-event").arg(event);
    }
    if let Some(ref conditions) = req.trigger_conditions {
        cmd.arg("--trigger-conditions").arg(conditions.to_string());
    }
    if let Some(ref coll_id) = req.collection_id {
        cmd.arg("--collection-id").arg(coll_id.to_string());
    }
    cmd.arg("--requested-by").arg(user.node_id.to_string());

    cli_dispatch::set_database_url(&mut cmd)
        .map_err(|e| internal_error(&e))?;
    cli_dispatch::forward_env(&mut cmd, "AI_GATEWAY_URL");
    cli_dispatch::forward_env(&mut cmd, "LITELLM_MASTER_KEY");
    cli_dispatch::forward_env(&mut cmd, "AI_SCRIPT_MODEL");

    let ai_result = cli_dispatch::run_cli_tool("synops-ai", &mut cmd)
        .await
        .map_err(|e| internal_error(&format!("synops-ai feilet: {e}")))?;

    let generated_script = ai_result
        .get("script")
        .and_then(|v| v.as_str())
        .ok_or_else(|| internal_error("synops-ai returnerte ingen script"))?
        .to_string();

    // Valider scriptet med kompilatoren
    let compile_result = script_compiler::compile_script(&state.db, &generated_script)
        .await
        .map_err(|e| internal_error(&format!("Kompileringsfeil: {e}")))?;

    let compile_json = serde_json::to_value(&compile_result)
        .map_err(|e| internal_error(&format!("Serialisering: {e}")))?;

    tracing::info!(
        user = %user.node_id,
        has_errors = compile_result.has_errors(),
        "AI-script generert og validert"
    );

    Ok(Json(AiSuggestScriptResponse {
        status: if compile_result.has_errors() {
            "generated_with_errors".to_string()
        } else {
            "completed".to_string()
        },
        script: Some(generated_script),
        compile_result: Some(compile_json),
        work_item_id: None,
        message: None,
    }))
}

// =============================================================================
// POST /intentions/clip_url — klipp URL og opprett content-node
// =============================================================================

#[derive(Deserialize)]
pub struct ClipUrlRequest {
    /// URL som skal klippes.
    pub url: String,
    /// Skriv resultat til database (default: true).
    #[serde(default = "default_true")]
    pub write: bool,
    /// Tving bruk av Playwright (headless browser).
    #[serde(default)]
    pub playwright: bool,
}

fn default_true() -> bool {
    true
}

#[derive(Serialize)]
pub struct ClipUrlResponse {
    pub job_id: Uuid,
}

/// POST /intentions/clip_url
///
/// Legger en `clip_url`-jobb i køen.
/// synops-clip henter artikkelen, parser med Readability,
/// og oppretter content-node med AI-oppsummering.
pub async fn clip_url(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<ClipUrlRequest>,
) -> Result<Json<ClipUrlResponse>, (StatusCode, Json<ErrorResponse>)> {
    // Enkel URL-validering
    if !req.url.starts_with("http://") && !req.url.starts_with("https://") {
        return Err(bad_request("URL må starte med http:// eller https://"));
    }

    let payload = serde_json::json!({
        "url": req.url,
        "created_by": user.node_id.to_string(),
        "write": req.write,
        "playwright": req.playwright,
    });

    let job_id = crate::jobs::enqueue(
        &state.db,
        "clip_url",
        payload,
        None,
        3, // Lav prioritet — ikke tidskritisk
    )
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "Kunne ikke legge clip_url-jobb i kø");
        internal_error("Kunne ikke starte URL-klipping")
    })?;

    tracing::info!(
        job_id = %job_id,
        url = %req.url,
        user = %user.node_id,
        "clip_url-jobb lagt i kø"
    );

    Ok(Json(ClipUrlResponse { job_id }))
}

// =============================================================================
// Feed-abonnement (oppgave 29.3)
// =============================================================================

#[derive(Deserialize)]
pub struct ConfigureFeedSubscriptionRequest {
    /// Samlings-ID
    pub collection_id: Uuid,
    /// Feed-URL (RSS/Atom)
    pub url: String,
    /// Poll-intervall i minutter (default: 30)
    #[serde(default = "default_feed_interval")]
    pub interval_minutes: u32,
    /// Mål: "inbox" eller "channel" (default: "inbox")
    #[serde(default = "default_feed_target")]
    pub target: String,
    /// Aktivert (default: true)
    #[serde(default = "default_true")]
    pub enabled: bool,
}

fn default_feed_interval() -> u32 { 30 }
fn default_feed_target() -> String { "inbox".to_string() }

#[derive(Deserialize)]
pub struct RemoveFeedSubscriptionRequest {
    /// Samlings-ID
    pub collection_id: Uuid,
    /// Feed-URL å fjerne
    pub url: String,
}

/// POST /intentions/configure_feed_subscription
///
/// Legger til eller oppdaterer et feed-abonnement på en samling.
/// Lagres i samlingens `metadata.feed_subscriptions[]`.
pub async fn configure_feed_subscription(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<ConfigureFeedSubscriptionRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
    // Valider URL
    if !req.url.starts_with("http://") && !req.url.starts_with("https://") {
        return Err(bad_request("Feed-URL må starte med http:// eller https://"));
    }
    if req.interval_minutes < 5 {
        return Err(bad_request("Intervall må være minst 5 minutter"));
    }
    if req.target != "inbox" && req.target != "channel" {
        return Err(bad_request("Target må være 'inbox' eller 'channel'"));
    }

    // Sjekk at samlingen eksisterer og brukeren har tilgang
    let collection: Option<(String, serde_json::Value)> = sqlx::query_as(
        "SELECT node_kind, COALESCE(metadata, '{}'::jsonb) FROM nodes WHERE id = $1",
    )
    .bind(req.collection_id)
    .fetch_optional(&state.db)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "PG-feil ved oppslag av samling");
        internal_error("Kunne ikke slå opp samling")
    })?;

    let (kind, metadata) = collection.ok_or_else(|| bad_request("Samling ikke funnet"))?;
    if kind != "collection" {
        return Err(bad_request("Noden er ikke en samling"));
    }

    // Les eksisterende abonnementer
    let mut subs: Vec<crate::feed_poller::FeedSubscription> = metadata
        .get("feed_subscriptions")
        .and_then(|v| serde_json::from_value(v.clone()).ok())
        .unwrap_or_default();

    // Oppdater eksisterende eller legg til ny
    let new_sub = crate::feed_poller::FeedSubscription {
        url: req.url.clone(),
        interval_minutes: req.interval_minutes,
        target: req.target.clone(),
        last_polled_at: None,
        enabled: Some(req.enabled),
    };

    if let Some(existing) = subs.iter_mut().find(|s| s.url == req.url) {
        existing.interval_minutes = req.interval_minutes;
        existing.target = req.target.clone();
        existing.enabled = Some(req.enabled);
        tracing::info!(url = %req.url, "Feed-abonnement oppdatert");
    } else {
        subs.push(new_sub);
        tracing::info!(url = %req.url, "Feed-abonnement lagt til");
    }

    // Skriv tilbake til metadata
    sqlx::query(
        r#"
        UPDATE nodes
        SET metadata = jsonb_set(
            COALESCE(metadata, '{}'::jsonb),
            '{feed_subscriptions}',
            $2
        )
        WHERE id = $1
        "#,
    )
    .bind(req.collection_id)
    .bind(serde_json::to_value(&subs).unwrap())
    .execute(&state.db)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "Kunne ikke oppdatere feed_subscriptions");
        internal_error("Kunne ikke lagre feed-abonnement")
    })?;

    tracing::info!(
        collection_id = %req.collection_id,
        url = %req.url,
        user = %user.node_id,
        interval = req.interval_minutes,
        target = %req.target,
        "Feed-abonnement konfigurert"
    );

    Ok(Json(serde_json::json!({
        "status": "ok",
        "collection_id": req.collection_id,
        "url": req.url,
        "interval_minutes": req.interval_minutes,
        "target": req.target,
        "enabled": req.enabled,
        "subscriptions_count": subs.len(),
    })))
}

/// POST /intentions/remove_feed_subscription
///
/// Fjerner et feed-abonnement fra en samling.
pub async fn remove_feed_subscription(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<RemoveFeedSubscriptionRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
    // Les eksisterende abonnementer
    let metadata: Option<serde_json::Value> = sqlx::query_scalar(
        "SELECT COALESCE(metadata, '{}'::jsonb) FROM nodes WHERE id = $1 AND node_kind = 'collection'",
    )
    .bind(req.collection_id)
    .fetch_optional(&state.db)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "PG-feil");
        internal_error("Kunne ikke hente samling")
    })?;

    let metadata = metadata.ok_or_else(|| bad_request("Samling ikke funnet"))?;

    let mut subs: Vec<crate::feed_poller::FeedSubscription> = metadata
        .get("feed_subscriptions")
        .and_then(|v| serde_json::from_value(v.clone()).ok())
        .unwrap_or_default();

    let before = subs.len();
    subs.retain(|s| s.url != req.url);

    if subs.len() == before {
        return Err(bad_request("Feed-abonnement ikke funnet"));
    }

    // Skriv tilbake
    sqlx::query(
        r#"
        UPDATE nodes
        SET metadata = jsonb_set(
            COALESCE(metadata, '{}'::jsonb),
            '{feed_subscriptions}',
            $2
        )
        WHERE id = $1
        "#,
    )
    .bind(req.collection_id)
    .bind(serde_json::to_value(&subs).unwrap())
    .execute(&state.db)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "Kunne ikke oppdatere feed_subscriptions");
        internal_error("Kunne ikke fjerne feed-abonnement")
    })?;

    tracing::info!(
        collection_id = %req.collection_id,
        url = %req.url,
        user = %user.node_id,
        "Feed-abonnement fjernet"
    );

    Ok(Json(serde_json::json!({
        "status": "ok",
        "collection_id": req.collection_id,
        "url": req.url,
        "removed": true,
        "subscriptions_count": subs.len(),
    })))
}

// =============================================================================
// Kalender-abonnement (oppgave 29.12)
// =============================================================================

#[derive(Deserialize)]
pub struct ConfigureCalendarSubscriptionRequest {
    /// Samlings-ID
    pub collection_id: Uuid,
    /// Kalender-URL (ICS/CalDAV)
    pub url: String,
    /// Poll-intervall i minutter (default: 60)
    #[serde(default = "default_calendar_interval")]
    pub interval_minutes: u32,
    /// Aktivert (default: true)
    #[serde(default = "default_true")]
    pub enabled: bool,
}

fn default_calendar_interval() -> u32 { 60 }

#[derive(Deserialize)]
pub struct RemoveCalendarSubscriptionRequest {
    /// Samlings-ID
    pub collection_id: Uuid,
    /// Kalender-URL å fjerne
    pub url: String,
}

/// POST /intentions/configure_calendar_subscription
///
/// Legger til eller oppdaterer et kalender-abonnement på en samling.
/// Lagres i samlingens `metadata.calendar_subscriptions[]`.
pub async fn configure_calendar_subscription(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<ConfigureCalendarSubscriptionRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
    if !req.url.starts_with("http://") && !req.url.starts_with("https://") {
        return Err(bad_request("Kalender-URL må starte med http:// eller https://"));
    }
    if req.interval_minutes < 15 {
        return Err(bad_request("Intervall må være minst 15 minutter"));
    }

    // Sjekk at samlingen eksisterer og er en samling
    let collection: Option<(String, serde_json::Value)> = sqlx::query_as(
        "SELECT node_kind, COALESCE(metadata, '{}'::jsonb) FROM nodes WHERE id = $1",
    )
    .bind(req.collection_id)
    .fetch_optional(&state.db)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "PG-feil ved oppslag av samling");
        internal_error("Kunne ikke slå opp samling")
    })?;

    let (kind, metadata) = collection.ok_or_else(|| bad_request("Samling ikke funnet"))?;
    if kind != "collection" {
        return Err(bad_request("Noden er ikke en samling"));
    }

    // Les eksisterende abonnementer
    let mut subs: Vec<crate::calendar_poller::CalendarSubscription> = metadata
        .get("calendar_subscriptions")
        .and_then(|v| serde_json::from_value(v.clone()).ok())
        .unwrap_or_default();

    let new_sub = crate::calendar_poller::CalendarSubscription {
        url: req.url.clone(),
        interval_minutes: req.interval_minutes,
        last_polled_at: None,
        enabled: Some(req.enabled),
    };

    if let Some(existing) = subs.iter_mut().find(|s| s.url == req.url) {
        existing.interval_minutes = req.interval_minutes;
        existing.enabled = Some(req.enabled);
        tracing::info!(url = %req.url, "Kalender-abonnement oppdatert");
    } else {
        subs.push(new_sub);
        tracing::info!(url = %req.url, "Kalender-abonnement lagt til");
    }

    // Skriv tilbake til metadata
    sqlx::query(
        r#"
        UPDATE nodes
        SET metadata = jsonb_set(
            COALESCE(metadata, '{}'::jsonb),
            '{calendar_subscriptions}',
            $2
        )
        WHERE id = $1
        "#,
    )
    .bind(req.collection_id)
    .bind(serde_json::to_value(&subs).unwrap())
    .execute(&state.db)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "Kunne ikke oppdatere calendar_subscriptions");
        internal_error("Kunne ikke lagre kalender-abonnement")
    })?;

    tracing::info!(
        collection_id = %req.collection_id,
        url = %req.url,
        user = %user.node_id,
        interval = req.interval_minutes,
        "Kalender-abonnement konfigurert"
    );

    Ok(Json(serde_json::json!({
        "status": "ok",
        "collection_id": req.collection_id,
        "url": req.url,
        "interval_minutes": req.interval_minutes,
        "enabled": req.enabled,
        "subscriptions_count": subs.len(),
    })))
}

/// POST /intentions/remove_calendar_subscription
///
/// Fjerner et kalender-abonnement fra en samling.
pub async fn remove_calendar_subscription(
    State(state): State<AppState>,
    user: AuthUser,
    Json(req): Json<RemoveCalendarSubscriptionRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
    let metadata: Option<serde_json::Value> = sqlx::query_scalar(
        "SELECT COALESCE(metadata, '{}'::jsonb) FROM nodes WHERE id = $1 AND node_kind = 'collection'",
    )
    .bind(req.collection_id)
    .fetch_optional(&state.db)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "PG-feil");
        internal_error("Kunne ikke hente samling")
    })?;

    let metadata = metadata.ok_or_else(|| bad_request("Samling ikke funnet"))?;

    let mut subs: Vec<crate::calendar_poller::CalendarSubscription> = metadata
        .get("calendar_subscriptions")
        .and_then(|v| serde_json::from_value(v.clone()).ok())
        .unwrap_or_default();

    let before = subs.len();
    subs.retain(|s| s.url != req.url);

    if subs.len() == before {
        return Err(bad_request("Kalender-abonnement ikke funnet"));
    }

    sqlx::query(
        r#"
        UPDATE nodes
        SET metadata = jsonb_set(
            COALESCE(metadata, '{}'::jsonb),
            '{calendar_subscriptions}',
            $2
        )
        WHERE id = $1
        "#,
    )
    .bind(req.collection_id)
    .bind(serde_json::to_value(&subs).unwrap())
    .execute(&state.db)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "Kunne ikke oppdatere calendar_subscriptions");
        internal_error("Kunne ikke fjerne kalender-abonnement")
    })?;

    tracing::info!(
        collection_id = %req.collection_id,
        url = %req.url,
        user = %user.node_id,
        "Kalender-abonnement fjernet"
    );

    Ok(Json(serde_json::json!({
        "status": "ok",
        "collection_id": req.collection_id,
        "url": req.url,
        "removed": true,
        "subscriptions_count": subs.len(),
    })))
}

// =============================================================================
// Utgående varsler (oppgave 26.7)
// =============================================================================

#[derive(Deserialize)]
pub struct SendNotificationRequest {
    /// Mottaker (node_id for bruker).
    pub to: Uuid,
    /// Varseltekst.
    pub message: String,
    /// Emne for epost (valgfritt).
    #[serde(default = "default_notification_subject")]
    pub subject: String,
    /// Kanal: "email", "ws", "both" (default: "both").
    #[serde(default = "default_notification_channel")]
    pub channel: String,
    /// Varslingstype for preferansesjekk (f.eks. "task_assigned", "article_approved").
    #[serde(default)]
    pub notification_type: Option<String>,
}

fn default_notification_subject() -> String {
    "Varsel fra Synops".to_string()
}

fn default_notification_channel() -> String {
    "both".to_string()
}

/// POST /intentions/send_notification
///
/// Legger en `send_notification`-jobb i køen. Jobbkøen delegerer til
/// `synops-notify` som sjekker brukerens preferanser og sender via
/// valgt kanal (epost, WebSocket, eller begge).
///
/// Admin-only: kun administratorer kan sende varsler til vilkårlige brukere.
pub async fn send_notification(
    State(state): State<AppState>,
    _admin: AdminUser,
    Json(req): Json<SendNotificationRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
    // Valider kanal
    if !["email", "ws", "both"].contains(&req.channel.as_str()) {
        return Err(bad_request(&format!(
            "Ugyldig kanal: '{}'. Gyldige verdier: email, ws, both",
            req.channel
        )));
    }

    if req.message.trim().is_empty() {
        return Err(bad_request("message kan ikke være tom"));
    }

    // Verifiser at mottaker eksisterer og er person/agent
    let node_kind: Option<String> = sqlx::query_scalar(
        "SELECT node_kind::text FROM nodes WHERE id = $1",
    )
    .bind(req.to)
    .fetch_optional(&state.db)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "PG-feil ved oppslag av mottaker");
        internal_error("Databasefeil")
    })?;

    let node_kind = node_kind.ok_or_else(|| bad_request("Mottaker-node finnes ikke"))?;
    if !["person", "agent"].contains(&node_kind.as_str()) {
        return Err(bad_request(&format!(
            "Mottaker er {node_kind}, ikke person/agent"
        )));
    }

    // Bygg payload og legg i jobbkø
    let mut payload = serde_json::json!({
        "to": req.to.to_string(),
        "message": req.message,
        "subject": req.subject,
        "channel": req.channel,
    });
    if let Some(ref ntype) = req.notification_type {
        payload["notification_type"] = serde_json::Value::String(ntype.clone());
    }

    let job_id = crate::jobs::enqueue(
        &state.db,
        "send_notification",
        payload,
        None,
        10, // Høy prioritet — brukervendt varsel
    )
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "Kunne ikke legge varsel-jobb i kø");
        internal_error("Kunne ikke opprette varsel-jobb")
    })?;

    tracing::info!(
        job_id = %job_id,
        to = %req.to,
        channel = %req.channel,
        "Varsel lagt i jobbkø"
    );

    Ok(Json(serde_json::json!({
        "status": "queued",
        "job_id": job_id,
        "to": req.to,
        "channel": req.channel,
    })))
}

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

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

    #[test]
    fn test_validate_traits_ok_empty() {
        let meta = json!({});
        assert!(validate_collection_traits("collection", &meta).is_ok());
    }

    #[test]
    fn test_validate_traits_ok_known() {
        let meta = json!({
            "traits": {
                "publishing": { "slug": "test" },
                "rss": { "format": "atom" },
                "editor": { "preset": "longform" }
            }
        });
        assert!(validate_collection_traits("collection", &meta).is_ok());
    }

    #[test]
    fn test_validate_traits_rejects_unknown() {
        let meta = json!({
            "traits": {
                "publishing": {},
                "banana": {}
            }
        });
        let err = validate_collection_traits("collection", &meta).unwrap_err();
        assert!(err.contains("banana"), "Feilmelding skal nevne ukjent trait: {err}");
    }

    #[test]
    fn test_validate_traits_rejects_non_object() {
        let meta = json!({ "traits": ["publishing"] });
        let err = validate_collection_traits("collection", &meta).unwrap_err();
        assert!(err.contains("objekt"), "Feilmelding: {err}");
    }

    #[test]
    fn test_validate_traits_skips_non_collection() {
        let meta = json!({ "traits": { "totally_invalid": {} } });
        assert!(validate_collection_traits("content", &meta).is_ok());
        assert!(validate_collection_traits("person", &meta).is_ok());
    }

    // -- AI-preset validering (oppgave 18.1) --

    fn valid_ai_preset_meta() -> serde_json::Value {
        json!({
            "prompt": "Fiks teksten",
            "model_profile": "flash",
            "category": "standard",
            "default_direction": "tool_to_node",
            "icon": "sparkles",
            "color": "#8B5CF6"
        })
    }

    #[test]
    fn test_validate_ai_preset_ok() {
        let meta = valid_ai_preset_meta();
        assert!(validate_ai_preset_metadata("ai_preset", &meta).is_ok());
    }

    #[test]
    fn test_validate_ai_preset_skips_other_kinds() {
        // Ugyldig metadata skal ignoreres for andre node_kinds
        let meta = json!({});
        assert!(validate_ai_preset_metadata("content", &meta).is_ok());
        assert!(validate_ai_preset_metadata("collection", &meta).is_ok());
    }

    #[test]
    fn test_validate_ai_preset_missing_prompt() {
        let mut meta = valid_ai_preset_meta();
        meta.as_object_mut().unwrap().remove("prompt");
        let err = validate_ai_preset_metadata("ai_preset", &meta).unwrap_err();
        assert!(err.contains("prompt"), "Feilmelding: {err}");
    }

    #[test]
    fn test_validate_ai_preset_empty_prompt() {
        let mut meta = valid_ai_preset_meta();
        meta["prompt"] = json!("");
        let err = validate_ai_preset_metadata("ai_preset", &meta).unwrap_err();
        assert!(err.contains("prompt"), "Feilmelding: {err}");
    }

    #[test]
    fn test_validate_ai_preset_invalid_model_profile() {
        let mut meta = valid_ai_preset_meta();
        meta["model_profile"] = json!("ultra");
        let err = validate_ai_preset_metadata("ai_preset", &meta).unwrap_err();
        assert!(err.contains("model_profile"), "Feilmelding: {err}");
    }

    #[test]
    fn test_validate_ai_preset_invalid_category() {
        let mut meta = valid_ai_preset_meta();
        meta["category"] = json!("premium");
        let err = validate_ai_preset_metadata("ai_preset", &meta).unwrap_err();
        assert!(err.contains("category"), "Feilmelding: {err}");
    }

    #[test]
    fn test_validate_ai_preset_invalid_direction() {
        let mut meta = valid_ai_preset_meta();
        meta["default_direction"] = json!("left");
        let err = validate_ai_preset_metadata("ai_preset", &meta).unwrap_err();
        assert!(err.contains("default_direction"), "Feilmelding: {err}");
    }

    #[test]
    fn test_validate_ai_preset_invalid_color() {
        let mut meta = valid_ai_preset_meta();
        meta["color"] = json!("red");
        let err = validate_ai_preset_metadata("ai_preset", &meta).unwrap_err();
        assert!(err.contains("color"), "Feilmelding: {err}");
    }

    #[test]
    fn test_validate_traits_all_known() {
        // Verifiser at alle traits fra katalogen er gyldige
        let all_traits = vec![
            "editor", "versioning", "collaboration", "translation", "templates",
            "publishing", "rss", "newsletter", "custom_domain", "analytics", "embed", "api",
            "podcast", "recording", "transcription", "tts", "clips", "playlist", "studio",
            "chat", "forum", "comments", "guest_input", "announcements", "polls", "qa",
            "kanban", "calendar", "timeline", "table", "gallery", "bookmarks", "tags",
            "knowledge_graph", "wiki", "glossary", "faq", "bibliography",
            "auto_tag", "auto_summarize", "digest", "bridge", "moderation", "orchestration",
            "membership", "roles", "invites", "paywall", "directory",
            "webhook", "import", "export", "ical_sync",
        ];
        let mut traits_obj = serde_json::Map::new();
        for t in &all_traits {
            traits_obj.insert(t.to_string(), json!({}));
        }
        let meta = json!({ "traits": traits_obj });
        assert!(validate_collection_traits("collection", &meta).is_ok());
    }
}
