// Tunge spørringer — lesestien via PostgreSQL med RLS.
//
// For søk, statistikk, og graf-traversering brukes PG direkte.
// Alle spørringer kjøres med SET LOCAL ROLE synops_reader, som er underlagt
// RLS-policies. Brukerens node_id settes som sesjonsvariabel.
//
// Ref: docs/retninger/datalaget.md (tunge spørringer-seksjonen)

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

use crate::auth::AuthUser;
use crate::AppState;
use crate::intentions::ErrorResponse;

/// Terskelverdi for treg PG-spørring (millisekunder).
const SLOW_QUERY_THRESHOLD_MS: f64 = 100.0;

/// Logger en spørring som treg hvis varigheten overstiger terskelen.
fn log_slow_query(query_name: &str, duration_ms: f64) {
    if duration_ms >= SLOW_QUERY_THRESHOLD_MS {
        tracing::warn!(
            query = query_name,
            duration_ms = duration_ms,
            threshold_ms = SLOW_QUERY_THRESHOLD_MS,
            "slow_query"
        );
    }
}

// =============================================================================
// GET /query/segments — transkripsjons-segmenter for en node
// =============================================================================

#[derive(Deserialize)]
pub struct QuerySegmentsRequest {
    /// Node-ID for media-noden.
    pub node_id: Uuid,
}

#[derive(Serialize, sqlx::FromRow)]
pub struct SegmentResult {
    pub id: i64,
    pub seq: i32,
    pub start_ms: i32,
    pub end_ms: i32,
    pub content: String,
    pub edited: bool,
}

#[derive(Serialize)]
pub struct QuerySegmentsResponse {
    pub segments: Vec<SegmentResult>,
    pub transcribed_at: Option<String>,
}

/// GET /query/segments?node_id=...
///
/// Henter nyeste transkripsjons-segmenter for en media-node.
/// Verifiserer tilgang via RLS på nodes-tabellen først.
pub async fn query_segments(
    State(state): State<AppState>,
    user: AuthUser,
    axum::extract::Query(params): axum::extract::Query<QuerySegmentsRequest>,
) -> Result<Json<QuerySegmentsResponse>, (StatusCode, Json<ErrorResponse>)> {
    let result = run_query_segments(&state.db, user.node_id, params.node_id).await;
    match result {
        Ok(resp) => Ok(Json(resp)),
        Err(e) => {
            tracing::error!(error = %e, "query_segments feilet");
            Err(internal_error("Databasefeil ved henting av segmenter"))
        }
    }
}

async fn run_query_segments(
    db: &PgPool,
    user_node_id: Uuid,
    node_id: Uuid,
) -> Result<QuerySegmentsResponse, sqlx::Error> {
    // Verifiser tilgang: sjekk at brukeren kan se noden via RLS
    let mut tx = db.begin().await?;
    set_rls_context(&mut tx, user_node_id).await?;

    let exists = sqlx::query_scalar::<_, bool>(
        "SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1)",
    )
    .bind(node_id)
    .fetch_one(&mut *tx)
    .await?;

    tx.commit().await?;

    if !exists {
        return Ok(QuerySegmentsResponse {
            segments: vec![],
            transcribed_at: None,
        });
    }

    // Hent nyeste transcribed_at for denne noden
    let latest: Option<(chrono::DateTime<chrono::Utc>,)> = sqlx::query_as(
        "SELECT transcribed_at FROM transcription_segments WHERE node_id = $1 ORDER BY transcribed_at DESC LIMIT 1",
    )
    .bind(node_id)
    .fetch_optional(db)
    .await?;

    let Some((transcribed_at,)) = latest else {
        return Ok(QuerySegmentsResponse {
            segments: vec![],
            transcribed_at: None,
        });
    };

    // Hent alle segmenter for nyeste kjøring
    let segments = sqlx::query_as::<_, SegmentResult>(
        r#"
        SELECT id, seq, start_ms, end_ms, content, edited
        FROM transcription_segments
        WHERE node_id = $1 AND transcribed_at = $2
        ORDER BY seq
        "#,
    )
    .bind(node_id)
    .bind(transcribed_at)
    .fetch_all(db)
    .await?;

    Ok(QuerySegmentsResponse {
        segments,
        transcribed_at: Some(transcribed_at.to_rfc3339()),
    })
}

// =============================================================================
// GET /query/transcription_versions — alle transkripsjonsversjoner for en node
// =============================================================================

#[derive(Deserialize)]
pub struct QueryVersionsRequest {
    pub node_id: Uuid,
}

#[derive(Serialize)]
pub struct TranscriptionVersion {
    pub transcribed_at: String,
    pub segment_count: i64,
    pub edited_count: i64,
}

#[derive(Serialize)]
pub struct QueryVersionsResponse {
    pub versions: Vec<TranscriptionVersion>,
}

/// GET /query/transcription_versions?node_id=...
///
/// Lister alle transkripsjonsversjoner for en node, sortert nyeste først.
pub async fn query_transcription_versions(
    State(state): State<AppState>,
    user: AuthUser,
    axum::extract::Query(params): axum::extract::Query<QueryVersionsRequest>,
) -> Result<Json<QueryVersionsResponse>, (StatusCode, Json<ErrorResponse>)> {
    // Verifiser tilgang
    let mut tx = state.db.begin().await.map_err(|e| {
        tracing::error!(error = %e, "Transaksjon feilet");
        internal_error("Databasefeil")
    })?;
    set_rls_context(&mut tx, user.node_id).await.map_err(|e| {
        tracing::error!(error = %e, "RLS-kontekst feilet");
        internal_error("Databasefeil")
    })?;

    let exists = sqlx::query_scalar::<_, bool>(
        "SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1)",
    )
    .bind(params.node_id)
    .fetch_one(&mut *tx)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "Tilgangssjekk feilet");
        internal_error("Databasefeil")
    })?;

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

    if !exists {
        return Ok(Json(QueryVersionsResponse { versions: vec![] }));
    }

    let rows: Vec<(chrono::DateTime<chrono::Utc>, i64, i64)> = sqlx::query_as(
        r#"
        SELECT transcribed_at, COUNT(*) as segment_count,
               COUNT(*) FILTER (WHERE edited) as edited_count
        FROM transcription_segments
        WHERE node_id = $1
        GROUP BY transcribed_at
        ORDER BY transcribed_at DESC
        "#,
    )
    .bind(params.node_id)
    .fetch_all(&state.db)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "Feil ved henting av versjoner");
        internal_error("Databasefeil")
    })?;

    let versions = rows
        .into_iter()
        .map(|(ts, count, edited)| TranscriptionVersion {
            transcribed_at: ts.to_rfc3339(),
            segment_count: count,
            edited_count: edited,
        })
        .collect();

    Ok(Json(QueryVersionsResponse { versions }))
}

// =============================================================================
// GET /query/segments_version — segmenter for en spesifikk versjon
// =============================================================================

#[derive(Deserialize)]
pub struct QuerySegmentsVersionRequest {
    pub node_id: Uuid,
    pub transcribed_at: String,
}

/// GET /query/segments_version?node_id=...&transcribed_at=...
///
/// Henter segmenter for en spesifikk transkripsjonsversjon.
pub async fn query_segments_version(
    State(state): State<AppState>,
    user: AuthUser,
    axum::extract::Query(params): axum::extract::Query<QuerySegmentsVersionRequest>,
) -> Result<Json<QuerySegmentsResponse>, (StatusCode, Json<ErrorResponse>)> {
    // Verifiser tilgang
    let mut tx = state.db.begin().await.map_err(|e| {
        tracing::error!(error = %e, "Transaksjon feilet");
        internal_error("Databasefeil")
    })?;
    set_rls_context(&mut tx, user.node_id).await.map_err(|e| {
        tracing::error!(error = %e, "RLS-kontekst feilet");
        internal_error("Databasefeil")
    })?;

    let exists = sqlx::query_scalar::<_, bool>(
        "SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1)",
    )
    .bind(params.node_id)
    .fetch_one(&mut *tx)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "Tilgangssjekk feilet");
        internal_error("Databasefeil")
    })?;

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

    if !exists {
        return Ok(Json(QuerySegmentsResponse {
            segments: vec![],
            transcribed_at: None,
        }));
    }

    let ts: chrono::DateTime<chrono::Utc> = params.transcribed_at.parse()
        .map_err(|_| {
            (StatusCode::BAD_REQUEST, Json(ErrorResponse {
                error: "Ugyldig transcribed_at-tidsstempel".to_string(),
            }))
        })?;

    let segments = sqlx::query_as::<_, SegmentResult>(
        r#"
        SELECT id, seq, start_ms, end_ms, content, edited
        FROM transcription_segments
        WHERE node_id = $1 AND transcribed_at = $2
        ORDER BY seq
        "#,
    )
    .bind(params.node_id)
    .bind(ts)
    .fetch_all(&state.db)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "Feil ved henting av segmenter");
        internal_error("Databasefeil")
    })?;

    Ok(Json(QuerySegmentsResponse {
        segments,
        transcribed_at: Some(ts.to_rfc3339()),
    }))
}

// =============================================================================
// GET /query/segments/srt — eksporter segmenter som nedlastbar SRT-fil
// =============================================================================

/// GET /query/segments/srt?node_id=...
///
/// Genererer en SRT-fil fra nyeste transkripsjons-segmenter.
/// Returnerer filen med Content-Disposition: attachment for nedlasting.
pub async fn export_srt(
    State(state): State<AppState>,
    user: AuthUser,
    axum::extract::Query(params): axum::extract::Query<QuerySegmentsRequest>,
) -> Result<Response, (StatusCode, Json<ErrorResponse>)> {
    let resp = run_query_segments(&state.db, user.node_id, params.node_id)
        .await
        .map_err(|e| {
            tracing::error!(error = %e, "export_srt feilet");
            internal_error("Databasefeil ved henting av segmenter")
        })?;

    if resp.segments.is_empty() {
        return Err((
            StatusCode::NOT_FOUND,
            Json(ErrorResponse {
                error: "Ingen transkripsjons-segmenter funnet".to_string(),
            }),
        ));
    }

    let srt = segments_to_srt(&resp.segments);

    Ok((
        StatusCode::OK,
        [
            (axum::http::header::CONTENT_TYPE, "application/x-subrip; charset=utf-8"),
            (axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"transcription.srt\""),
        ],
        srt,
    )
        .into_response())
}

/// Konverterer segmenter til SRT-format.
///
/// SRT-format:
/// ```text
/// 1
/// 00:00:00,000 --> 00:00:05,230
/// Hei og velkommen.
///
/// 2
/// 00:00:05,230 --> 00:00:10,500
/// I dag snakker vi om...
/// ```
fn segments_to_srt(segments: &[SegmentResult]) -> String {
    let mut srt = String::new();
    for (i, seg) in segments.iter().enumerate() {
        if i > 0 {
            srt.push('\n');
        }
        srt.push_str(&format!(
            "{}\n{} --> {}\n{}\n",
            seg.seq,
            format_srt_timestamp(seg.start_ms),
            format_srt_timestamp(seg.end_ms),
            seg.content,
        ));
    }
    srt
}

/// Formaterer millisekunder til SRT-tidsstempel: HH:MM:SS,mmm
fn format_srt_timestamp(ms: i32) -> String {
    let total_seconds = ms / 1000;
    let millis = ms % 1000;
    let hours = total_seconds / 3600;
    let minutes = (total_seconds % 3600) / 60;
    let seconds = total_seconds % 60;
    format!("{:02}:{:02}:{:02},{:03}", hours, minutes, seconds, millis)
}

// =============================================================================
// RLS-kontekst
// =============================================================================

/// Setter opp RLS-kontekst for en transaksjon.
/// Etter dette kallet er alle SELECT-spørringer filtrert via node_access.
///
/// MÅ kalles innenfor en transaksjon (SET LOCAL gjelder kun innenfor tx).
async fn set_rls_context(
    tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
    user_node_id: Uuid,
) -> Result<(), sqlx::Error> {
    // Sett brukerens node_id som sesjonsvariabel
    sqlx::query(&format!(
        "SET LOCAL app.current_node_id = '{}'",
        user_node_id
    ))
    .execute(&mut **tx)
    .await?;

    // Bytt til synops_reader-rollen (underlagt RLS)
    sqlx::query("SET LOCAL ROLE synops_reader")
        .execute(&mut **tx)
        .await?;

    Ok(())
}

// =============================================================================
// GET /query/nodes — søk og filtrering av noder
// =============================================================================

#[derive(Deserialize)]
pub struct QueryNodesRequest {
    /// Fritekst-søk i tittel og innhold. Valgfritt.
    pub q: Option<String>,
    /// Filtrer på node_kind. Valgfritt.
    pub kind: Option<String>,
    /// Maks antall resultater. Default: 50.
    pub limit: Option<i64>,
    /// Offset for paginering. Default: 0.
    pub offset: Option<i64>,
}

#[derive(Serialize, sqlx::FromRow)]
pub struct QueryNodeResult {
    pub id: Uuid,
    pub node_kind: String,
    pub title: Option<String>,
    pub content: Option<String>,
    pub visibility: String,
    pub metadata: serde_json::Value,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub created_by: Option<Uuid>,
}

#[derive(Serialize)]
pub struct QueryNodesResponse {
    pub nodes: Vec<QueryNodeResult>,
    pub total: i64,
}

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

/// GET /query/nodes?q=...&kind=...&limit=...&offset=...
///
/// Søk og filtrering av noder. Bruker RLS — returnerer kun noder
/// brukeren har tilgang til.
pub async fn query_nodes(
    State(state): State<AppState>,
    user: AuthUser,
    axum::extract::Query(params): axum::extract::Query<QueryNodesRequest>,
) -> Result<Json<QueryNodesResponse>, (StatusCode, Json<ErrorResponse>)> {
    let limit = params.limit.unwrap_or(50).min(200);
    let offset = params.offset.unwrap_or(0).max(0);

    let result = run_query_nodes(&state.db, user.node_id, &params.q, &params.kind, limit, offset).await;

    match result {
        Ok(resp) => Ok(Json(resp)),
        Err(e) => {
            tracing::error!(error = %e, "query_nodes feilet");
            Err(internal_error("Databasefeil ved søk"))
        }
    }
}

async fn run_query_nodes(
    db: &PgPool,
    user_node_id: Uuid,
    q: &Option<String>,
    kind: &Option<String>,
    limit: i64,
    offset: i64,
) -> Result<QueryNodesResponse, sqlx::Error> {
    let query_start = std::time::Instant::now();
    let mut tx = db.begin().await?;
    set_rls_context(&mut tx, user_node_id).await?;

    // Bygg spørring basert på filtre
    let (nodes, total) = if let Some(search) = q.as_deref().filter(|s| !s.is_empty()) {
        let search_pattern = format!("%{}%", search.replace('%', "\\%").replace('_', "\\_"));

        let nodes = sqlx::query_as::<_, QueryNodeResult>(
            r#"
            SELECT id, node_kind, title, content, visibility::text as visibility,
                   metadata, created_at, created_by
            FROM nodes
            WHERE (title ILIKE $1 OR content ILIKE $1)
            ORDER BY created_at DESC
            LIMIT $2 OFFSET $3
            "#,
        )
        .bind(&search_pattern)
        .bind(limit)
        .bind(offset)
        .fetch_all(&mut *tx)
        .await?;

        let total: (i64,) = sqlx::query_as(
            "SELECT COUNT(*) FROM nodes WHERE (title ILIKE $1 OR content ILIKE $1)",
        )
        .bind(&search_pattern)
        .fetch_one(&mut *tx)
        .await?;

        (nodes, total.0)
    } else if let Some(kind) = kind.as_deref().filter(|s| !s.is_empty()) {
        let nodes = sqlx::query_as::<_, QueryNodeResult>(
            r#"
            SELECT id, node_kind, title, content, visibility::text as visibility,
                   metadata, created_at, created_by
            FROM nodes
            WHERE node_kind = $1
            ORDER BY created_at DESC
            LIMIT $2 OFFSET $3
            "#,
        )
        .bind(kind)
        .bind(limit)
        .bind(offset)
        .fetch_all(&mut *tx)
        .await?;

        let total: (i64,) = sqlx::query_as(
            "SELECT COUNT(*) FROM nodes WHERE node_kind = $1",
        )
        .bind(kind)
        .fetch_one(&mut *tx)
        .await?;

        (nodes, total.0)
    } else {
        let nodes = sqlx::query_as::<_, QueryNodeResult>(
            r#"
            SELECT id, node_kind, title, content, visibility::text as visibility,
                   metadata, created_at, created_by
            FROM nodes
            ORDER BY created_at DESC
            LIMIT $1 OFFSET $2
            "#,
        )
        .bind(limit)
        .bind(offset)
        .fetch_all(&mut *tx)
        .await?;

        let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM nodes")
            .fetch_one(&mut *tx)
            .await?;

        (nodes, total.0)
    };

    // Transaksjon avsluttes — SET LOCAL tilbakestilles automatisk
    tx.commit().await?;

    log_slow_query("query_nodes", query_start.elapsed().as_secs_f64() * 1000.0);

    Ok(QueryNodesResponse { nodes, total })
}

// =============================================================================
// GET /query/aliases — brukerens alias-noder
// =============================================================================

#[derive(Serialize, sqlx::FromRow)]
pub struct AliasResult {
    pub node_id: Uuid,
    pub title: Option<String>,
    pub metadata: serde_json::Value,
    pub created_at: chrono::DateTime<chrono::Utc>,
}

#[derive(Serialize)]
pub struct QueryAliasesResponse {
    pub aliases: Vec<AliasResult>,
}

/// GET /query/aliases
///
/// Returnerer alle alias-noder for den autentiserte brukeren.
/// Henter via alias-edges (system=true) fra brukerens hovednode.
/// Ingen RLS nødvendig — vi spør direkte med brukerens node_id.
pub async fn query_aliases(
    State(state): State<AppState>,
    user: AuthUser,
) -> Result<Json<QueryAliasesResponse>, (StatusCode, Json<ErrorResponse>)> {
    let aliases = sqlx::query_as::<_, AliasResult>(
        r#"
        SELECT n.id AS node_id, n.title, n.metadata, n.created_at
        FROM edges e
        JOIN nodes n ON n.id = e.target_id
        WHERE e.source_id = $1
          AND e.edge_type = 'alias'
          AND e.system = true
        ORDER BY n.created_at
        "#,
    )
    .bind(user.node_id)
    .fetch_all(&state.db)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "query_aliases feilet");
        internal_error("Databasefeil ved henting av aliaser")
    })?;

    Ok(Json(QueryAliasesResponse { aliases }))
}

// =============================================================================
// GET /query/board — kanban-brett: noder med belongs_to-edge, gruppert på status
// =============================================================================

#[derive(Deserialize)]
pub struct QueryBoardRequest {
    /// Board-nodens ID.
    pub board_id: Uuid,
}

#[derive(Serialize)]
pub struct BoardCard {
    pub node_id: Uuid,
    pub title: Option<String>,
    pub content: Option<String>,
    pub node_kind: String,
    pub metadata: serde_json::Value,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub created_by: Option<Uuid>,
    /// Status-verdi fra status-edge metadata (null hvis ingen status-edge)
    pub status: Option<String>,
    /// Position fra belongs_to-edge metadata (default 0)
    pub position: f64,
    /// belongs_to-edge ID (for referanse)
    pub belongs_to_edge_id: Uuid,
    /// status-edge ID (null hvis ingen)
    pub status_edge_id: Option<Uuid>,
}

#[derive(Serialize)]
pub struct QueryBoardResponse {
    pub board_id: Uuid,
    pub board_title: Option<String>,
    /// Kolonne-definisjoner fra board-nodens metadata
    pub columns: Vec<String>,
    pub cards: Vec<BoardCard>,
}

/// GET /query/board?board_id=...
///
/// Henter alle kort (noder med belongs_to-edge) på et kanban-brett,
/// inkludert status-edges og posisjonsdata.
pub async fn query_board(
    State(state): State<AppState>,
    user: AuthUser,
    axum::extract::Query(params): axum::extract::Query<QueryBoardRequest>,
) -> Result<Json<QueryBoardResponse>, (StatusCode, Json<ErrorResponse>)> {
    // Verifiser tilgang til board-noden via RLS
    let mut tx = state.db.begin().await.map_err(|e| {
        tracing::error!(error = %e, "Transaksjon feilet");
        internal_error("Databasefeil")
    })?;
    set_rls_context(&mut tx, user.node_id).await.map_err(|e| {
        tracing::error!(error = %e, "RLS-kontekst feilet");
        internal_error("Databasefeil")
    })?;

    // Hent board-noden
    let board = sqlx::query_as::<_, (Option<String>, serde_json::Value)>(
        "SELECT title, metadata FROM nodes WHERE id = $1",
    )
    .bind(params.board_id)
    .fetch_optional(&mut *tx)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "Feil ved henting av board-node");
        internal_error("Databasefeil")
    })?;

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

    let Some((board_title, board_metadata)) = board else {
        return Err((
            StatusCode::NOT_FOUND,
            Json(ErrorResponse {
                error: format!("Board {} finnes ikke eller du har ikke tilgang", params.board_id),
            }),
        ));
    };

    // Hent kolonner fra board-metadata, fallback til standard
    let columns: Vec<String> = board_metadata
        .get("columns")
        .and_then(|v| serde_json::from_value(v.clone()).ok())
        .unwrap_or_else(|| vec!["todo".to_string(), "in_progress".to_string(), "done".to_string()]);

    // Hent alle kort: noder med belongs_to-edge til dette boardet
    let cards = sqlx::query_as::<_, (
        Uuid,           // n.id
        String,         // n.node_kind
        Option<String>, // n.title
        Option<String>, // n.content
        serde_json::Value, // n.metadata
        chrono::DateTime<chrono::Utc>, // n.created_at
        Option<Uuid>,   // n.created_by
        Uuid,           // bt.id (belongs_to edge)
        serde_json::Value, // bt.metadata
        Option<Uuid>,   // st.id (status edge)
        Option<serde_json::Value>, // st.metadata
    )>(
        r#"
        SELECT
            n.id, n.node_kind, n.title, n.content, n.metadata,
            n.created_at, n.created_by,
            bt.id AS belongs_to_edge_id, bt.metadata AS bt_metadata,
            st.id AS status_edge_id, st.metadata AS st_metadata
        FROM edges bt
        JOIN nodes n ON n.id = bt.source_id
        LEFT JOIN edges st ON st.source_id = n.id
            AND st.target_id = $1
            AND st.edge_type = 'status'
        WHERE bt.target_id = $1
          AND bt.edge_type = 'belongs_to'
        ORDER BY n.created_at
        "#,
    )
    .bind(params.board_id)
    .fetch_all(&state.db)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "Feil ved henting av board-kort");
        internal_error("Databasefeil ved henting av kort")
    })?;

    let board_cards: Vec<BoardCard> = cards
        .into_iter()
        .map(|(node_id, node_kind, title, content, metadata, created_at, created_by,
               belongs_to_edge_id, bt_metadata, status_edge_id, st_metadata)| {
            let status = st_metadata
                .as_ref()
                .and_then(|m| m.get("value"))
                .and_then(|v| v.as_str())
                .map(|s| s.to_string());

            let position = bt_metadata
                .get("position")
                .and_then(|v| v.as_f64())
                .unwrap_or(0.0);

            BoardCard {
                node_id,
                title,
                content,
                node_kind,
                metadata,
                created_at,
                created_by,
                status,
                position,
                belongs_to_edge_id,
                status_edge_id,
            }
        })
        .collect();

    Ok(Json(QueryBoardResponse {
        board_id: params.board_id,
        board_title,
        columns,
        cards: board_cards,
    }))
}

// =============================================================================
// GET /query/editorial_board — redaktørens arbeidsflate
// =============================================================================
//
// Viser noder med submitted_to-edge til en samling, gruppert på status.
// Brukes av frontend for Kanban-visning av redaksjonelle innsendinger.

#[derive(Deserialize)]
pub struct QueryEditorialBoardRequest {
    /// Samlings-nodens ID (target for submitted_to-edges).
    pub collection_id: Uuid,
}

#[derive(Serialize)]
pub struct EditorialCard {
    pub node_id: Uuid,
    pub title: Option<String>,
    pub content: Option<String>,
    pub node_kind: String,
    pub metadata: serde_json::Value,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub created_by: Option<Uuid>,
    /// Forfatterens navn (title fra person-noden)
    pub author_name: Option<String>,
    /// Status fra submitted_to-edge metadata
    pub status: String,
    /// submitted_to-edge ID
    pub submitted_to_edge_id: Uuid,
    /// Full metadata fra submitted_to-edge (inkl. feedback, publish_at, etc.)
    pub edge_metadata: serde_json::Value,
    /// ID til kommunikasjonsnode(r) knyttet til artikkelen (redaksjonell samtale)
    pub discussion_ids: Vec<Uuid>,
}

#[derive(Serialize)]
pub struct QueryEditorialBoardResponse {
    pub collection_id: Uuid,
    pub collection_title: Option<String>,
    /// Faste kolonner for redaksjonell arbeidsflate
    pub columns: Vec<String>,
    /// Visningsnavn for kolonnene
    pub column_labels: std::collections::HashMap<String, String>,
    pub cards: Vec<EditorialCard>,
}

/// GET /query/editorial_board?collection_id=...
///
/// Henter alle noder med submitted_to-edge til en samling,
/// inkludert status, metadata og forfatterinfo.
pub async fn query_editorial_board(
    State(state): State<AppState>,
    user: AuthUser,
    axum::extract::Query(params): axum::extract::Query<QueryEditorialBoardRequest>,
) -> Result<Json<QueryEditorialBoardResponse>, (StatusCode, Json<ErrorResponse>)> {
    // Verifiser tilgang til samlings-noden via RLS
    let mut tx = state.db.begin().await.map_err(|e| {
        tracing::error!(error = %e, "Transaksjon feilet");
        internal_error("Databasefeil")
    })?;
    set_rls_context(&mut tx, user.node_id).await.map_err(|e| {
        tracing::error!(error = %e, "RLS-kontekst feilet");
        internal_error("Databasefeil")
    })?;

    let collection = sqlx::query_as::<_, (Option<String>,)>(
        "SELECT title FROM nodes WHERE id = $1",
    )
    .bind(params.collection_id)
    .fetch_optional(&mut *tx)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "Feil ved henting av samlings-node");
        internal_error("Databasefeil")
    })?;

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

    let Some((collection_title,)) = collection else {
        return Err((
            StatusCode::NOT_FOUND,
            Json(ErrorResponse {
                error: format!(
                    "Samling {} finnes ikke eller du har ikke tilgang",
                    params.collection_id
                ),
            }),
        ));
    };

    // Hent alle noder med submitted_to-edge til denne samlingen,
    // pluss forfatterens navn via created_by → person-node
    let rows = sqlx::query_as::<_, (
        Uuid,                             // n.id
        String,                           // n.node_kind
        Option<String>,                   // n.title
        Option<String>,                   // n.content
        serde_json::Value,                // n.metadata
        chrono::DateTime<chrono::Utc>,    // n.created_at
        Option<Uuid>,                     // n.created_by
        Option<String>,                   // author.title (forfatterens navn)
        Uuid,                             // e.id (submitted_to edge)
        serde_json::Value,                // e.metadata
    )>(
        r#"
        SELECT
            n.id, n.node_kind, n.title, n.content, n.metadata,
            n.created_at, n.created_by,
            author.title AS author_name,
            e.id AS edge_id, e.metadata AS edge_metadata
        FROM edges e
        JOIN nodes n ON n.id = e.source_id
        LEFT JOIN nodes author ON author.id = n.created_by
            AND author.node_kind = 'person'
        WHERE e.target_id = $1
          AND e.edge_type = 'submitted_to'
        ORDER BY e.created_at DESC
        "#,
    )
    .bind(params.collection_id)
    .fetch_all(&state.db)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "Feil ved henting av editorial-kort");
        internal_error("Databasefeil ved henting av innsendinger")
    })?;

    // Samle alle artikkel-IDer for å hente diskusjoner i én spørring
    let article_ids: Vec<Uuid> = rows.iter().map(|r| r.0).collect();

    // Hent kommunikasjonsnoder som har belongs_to-edge til artiklene
    let discussions: Vec<(Uuid, Uuid)> = if !article_ids.is_empty() {
        sqlx::query_as::<_, (Uuid, Uuid)>(
            r#"
            SELECT e.source_id AS communication_id, e.target_id AS article_id
            FROM edges e
            JOIN nodes n ON n.id = e.source_id AND n.node_kind = 'communication'
            WHERE e.edge_type = 'belongs_to'
              AND e.target_id = ANY($1)
            "#,
        )
        .bind(&article_ids)
        .fetch_all(&state.db)
        .await
        .unwrap_or_default()
    } else {
        vec![]
    };

    // Bygg oppslag: artikkel_id → liste med diskusjons-IDer
    let mut discussion_map: std::collections::HashMap<Uuid, Vec<Uuid>> =
        std::collections::HashMap::new();
    for (comm_id, article_id) in discussions {
        discussion_map.entry(article_id).or_default().push(comm_id);
    }

    let cards: Vec<EditorialCard> = rows
        .into_iter()
        .map(|(node_id, node_kind, title, content, metadata, created_at, created_by,
               author_name, edge_id, edge_metadata)| {
            let status = edge_metadata
                .get("status")
                .and_then(|v| v.as_str())
                .unwrap_or("pending")
                .to_string();

            let discussion_ids = discussion_map
                .get(&node_id)
                .cloned()
                .unwrap_or_default();

            EditorialCard {
                node_id,
                title,
                content,
                node_kind,
                metadata,
                created_at,
                created_by,
                author_name,
                status,
                submitted_to_edge_id: edge_id,
                edge_metadata,
                discussion_ids,
            }
        })
        .collect();

    let columns = vec![
        "pending".to_string(),
        "in_review".to_string(),
        "approved".to_string(),
        "scheduled".to_string(),
    ];

    let mut column_labels = std::collections::HashMap::new();
    column_labels.insert("pending".to_string(), "Innkomne".to_string());
    column_labels.insert("in_review".to_string(), "Under vurdering".to_string());
    column_labels.insert("approved".to_string(), "Godkjent".to_string());
    column_labels.insert("scheduled".to_string(), "Planlagt".to_string());

    Ok(Json(QueryEditorialBoardResponse {
        collection_id: params.collection_id,
        collection_title,
        columns,
        column_labels,
        cards,
    }))
}

// =============================================================================
// GET /query/graph — graf-traversering fra en fokusnode
// =============================================================================

#[derive(Deserialize)]
pub struct QueryGraphRequest {
    /// Fokusnode å starte traverseringen fra. Valgfritt — uten returneres hele grafen.
    pub focus_id: Option<Uuid>,
    /// Maks dybde for traversering (1-3). Default: 2.
    pub depth: Option<i32>,
    /// Filtrer på edge_type (komma-separert). Valgfritt.
    pub edge_types: Option<String>,
    /// Filtrer på node_kind (komma-separert). Valgfritt.
    pub node_kinds: Option<String>,
}

#[derive(Serialize)]
pub struct GraphNode {
    pub id: Uuid,
    pub node_kind: String,
    pub title: Option<String>,
    pub visibility: String,
    pub metadata: serde_json::Value,
    pub created_at: chrono::DateTime<chrono::Utc>,
}

#[derive(Serialize)]
pub struct GraphEdge {
    pub id: Uuid,
    pub source_id: Uuid,
    pub target_id: Uuid,
    pub edge_type: String,
    pub metadata: serde_json::Value,
}

#[derive(Serialize)]
pub struct QueryGraphResponse {
    pub nodes: Vec<GraphNode>,
    pub edges: Vec<GraphEdge>,
}

/// GET /query/graph?focus_id=...&depth=...&edge_types=...&node_kinds=...
///
/// Returnerer noder og edges for graf-visualisering.
/// Med focus_id: traverserer grafen N ledd ut fra fokusnode.
/// Uten focus_id: returnerer alle synlige noder (maks 200) med edges mellom dem.
pub async fn query_graph(
    State(state): State<AppState>,
    user: AuthUser,
    axum::extract::Query(params): axum::extract::Query<QueryGraphRequest>,
) -> Result<Json<QueryGraphResponse>, (StatusCode, Json<ErrorResponse>)> {
    let depth = params.depth.unwrap_or(2).clamp(1, 3);

    let edge_type_filter: Option<Vec<String>> = params.edge_types.as_ref().map(|s| {
        s.split(',').map(|t| t.trim().to_string()).filter(|t| !t.is_empty()).collect()
    });

    let node_kind_filter: Option<Vec<String>> = params.node_kinds.as_ref().map(|s| {
        s.split(',').map(|t| t.trim().to_string()).filter(|t| !t.is_empty()).collect()
    });

    let result = run_query_graph(
        &state.db,
        user.node_id,
        params.focus_id,
        depth,
        &edge_type_filter,
        &node_kind_filter,
    )
    .await;

    match result {
        Ok(resp) => Ok(Json(resp)),
        Err(e) => {
            tracing::error!(error = %e, "query_graph feilet");
            Err(internal_error("Databasefeil ved graf-spørring"))
        }
    }
}

async fn run_query_graph(
    db: &PgPool,
    user_node_id: Uuid,
    focus_id: Option<Uuid>,
    depth: i32,
    edge_type_filter: &Option<Vec<String>>,
    node_kind_filter: &Option<Vec<String>>,
) -> Result<QueryGraphResponse, sqlx::Error> {
    let query_start = std::time::Instant::now();
    let mut tx = db.begin().await?;
    set_rls_context(&mut tx, user_node_id).await?;

    let (nodes, edges) = if let Some(focus) = focus_id {
        // Traverser fra fokusnode med rekursiv CTE
        let node_rows = sqlx::query_as::<_, (Uuid, String, Option<String>, String, serde_json::Value, chrono::DateTime<chrono::Utc>)>(
            r#"
            WITH RECURSIVE reachable(id, depth) AS (
                SELECT $1::uuid, 0
                UNION
                SELECT CASE WHEN e.source_id = r.id THEN e.target_id ELSE e.source_id END, r.depth + 1
                FROM reachable r
                JOIN edges e ON (e.source_id = r.id OR e.target_id = r.id)
                WHERE r.depth < $2
            )
            SELECT DISTINCT n.id, n.node_kind, n.title, n.visibility::text, n.metadata, n.created_at
            FROM reachable r
            JOIN nodes n ON n.id = r.id
            ORDER BY n.created_at DESC
            LIMIT 200
            "#,
        )
        .bind(focus)
        .bind(depth)
        .fetch_all(&mut *tx)
        .await?;

        // Samle opp node-IDer for å filtrere edges
        let node_ids: Vec<Uuid> = node_rows.iter().map(|r| r.0).collect();

        let edge_rows = sqlx::query_as::<_, (Uuid, Uuid, Uuid, String, serde_json::Value)>(
            r#"
            SELECT e.id, e.source_id, e.target_id, e.edge_type, e.metadata
            FROM edges e
            WHERE e.source_id = ANY($1) AND e.target_id = ANY($1)
              AND e.system = false
            "#,
        )
        .bind(&node_ids)
        .fetch_all(&mut *tx)
        .await?;

        (node_rows, edge_rows)
    } else {
        // Ingen fokus — returner alle synlige noder (begrenset)
        let node_rows = sqlx::query_as::<_, (Uuid, String, Option<String>, String, serde_json::Value, chrono::DateTime<chrono::Utc>)>(
            r#"
            SELECT n.id, n.node_kind, n.title, n.visibility::text, n.metadata, n.created_at
            FROM nodes n
            ORDER BY n.created_at DESC
            LIMIT 200
            "#,
        )
        .fetch_all(&mut *tx)
        .await?;

        let node_ids: Vec<Uuid> = node_rows.iter().map(|r| r.0).collect();

        let edge_rows = sqlx::query_as::<_, (Uuid, Uuid, Uuid, String, serde_json::Value)>(
            r#"
            SELECT e.id, e.source_id, e.target_id, e.edge_type, e.metadata
            FROM edges e
            WHERE e.source_id = ANY($1) AND e.target_id = ANY($1)
              AND e.system = false
            "#,
        )
        .bind(&node_ids)
        .fetch_all(&mut *tx)
        .await?;

        (node_rows, edge_rows)
    };

    tx.commit().await?;
    log_slow_query("query_graph", query_start.elapsed().as_secs_f64() * 1000.0);

    // Appliser klientside-filtre
    let mut graph_nodes: Vec<GraphNode> = nodes
        .into_iter()
        .map(|(id, node_kind, title, visibility, metadata, created_at)| GraphNode {
            id,
            node_kind,
            title,
            visibility,
            metadata,
            created_at,
        })
        .collect();

    if let Some(kinds) = node_kind_filter {
        if !kinds.is_empty() {
            // Behold alltid fokusnode selv om den ikke matcher filteret
            graph_nodes.retain(|n| kinds.contains(&n.node_kind) || focus_id == Some(n.id));
        }
    }

    let visible_ids: std::collections::HashSet<Uuid> =
        graph_nodes.iter().map(|n| n.id).collect();

    let mut graph_edges: Vec<GraphEdge> = edges
        .into_iter()
        .map(|(id, source_id, target_id, edge_type, metadata)| GraphEdge {
            id,
            source_id,
            target_id,
            edge_type,
            metadata,
        })
        .filter(|e| visible_ids.contains(&e.source_id) && visible_ids.contains(&e.target_id))
        .collect();

    if let Some(types) = edge_type_filter {
        if !types.is_empty() {
            graph_edges.retain(|e| types.contains(&e.edge_type));
        }
    }

    Ok(QueryGraphResponse {
        nodes: graph_nodes,
        edges: graph_edges,
    })
}

// =============================================================================
// GET /query/presentation_elements — presentasjonselementer for en artikkel
// =============================================================================
//
// Henter noder koblet til en artikkel via title/subtitle/summary/og_image/
// og_description-edges. Disse er separate noder med variantmetadata.
// Ref: docs/concepts/publisering.md § "Presentasjonselementer"

#[derive(Deserialize)]
pub struct QueryPresentationRequest {
    /// Artikkelens node-ID.
    pub article_id: Uuid,
}

#[derive(Serialize)]
pub struct PresentationElement {
    /// Presentasjonselementets node-ID.
    pub node_id: Uuid,
    /// Edge-ID (for oppdatering/sletting).
    pub edge_id: Uuid,
    /// Edge-type: title, subtitle, summary, og_image, og_description.
    pub element_type: String,
    /// Nodens tittel (brukt for title/subtitle).
    pub title: Option<String>,
    /// Nodens innhold (brukt for summary/og_description).
    pub content: Option<String>,
    /// Node-kind (content eller media).
    pub node_kind: String,
    /// Node metadata (inkl. cas_hash for media).
    pub metadata: serde_json::Value,
    /// Edge metadata (variant, language, ab_status etc.).
    pub edge_metadata: serde_json::Value,
    pub created_at: chrono::DateTime<chrono::Utc>,
}

#[derive(Serialize)]
pub struct QueryPresentationResponse {
    pub article_id: Uuid,
    pub elements: Vec<PresentationElement>,
}

/// GET /query/presentation_elements?article_id=...
///
/// Henter alle presentasjonselementer (tittel, undertittel, ingress,
/// OG-bilde, OG-beskrivelse) knyttet til en artikkel.
/// Returnerer nodene med edge-metadata (variant, ab_status etc.).
pub async fn query_presentation_elements(
    State(state): State<AppState>,
    user: AuthUser,
    axum::extract::Query(params): axum::extract::Query<QueryPresentationRequest>,
) -> Result<Json<QueryPresentationResponse>, (StatusCode, Json<ErrorResponse>)> {
    // Verifiser tilgang til artikkelen via RLS
    let mut tx = state.db.begin().await.map_err(|e| {
        tracing::error!(error = %e, "Transaksjon feilet");
        internal_error("Databasefeil")
    })?;
    set_rls_context(&mut tx, user.node_id).await.map_err(|e| {
        tracing::error!(error = %e, "RLS-kontekst feilet");
        internal_error("Databasefeil")
    })?;

    let exists = sqlx::query_scalar::<_, bool>(
        "SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1)",
    )
    .bind(params.article_id)
    .fetch_one(&mut *tx)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "Tilgangssjekk feilet");
        internal_error("Databasefeil")
    })?;

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

    if !exists {
        return Err((
            StatusCode::NOT_FOUND,
            Json(ErrorResponse {
                error: format!("Artikkel {} finnes ikke eller du har ikke tilgang", params.article_id),
            }),
        ));
    }

    // Hent presentasjonselementer: noder med title/subtitle/summary/og_image/og_description-edge
    // til artikkelen (source_id = element-node, target_id = article)
    let rows = sqlx::query_as::<_, (
        Uuid,                             // n.id
        Uuid,                             // e.id
        String,                           // e.edge_type
        Option<String>,                   // n.title
        Option<String>,                   // n.content
        String,                           // n.node_kind
        serde_json::Value,                // n.metadata
        serde_json::Value,                // e.metadata
        chrono::DateTime<chrono::Utc>,    // n.created_at
    )>(
        r#"
        SELECT
            n.id, e.id AS edge_id, e.edge_type,
            n.title, n.content, n.node_kind, n.metadata,
            e.metadata AS edge_metadata, n.created_at
        FROM edges e
        JOIN nodes n ON n.id = e.source_id
        WHERE e.target_id = $1
          AND e.edge_type IN ('title', 'subtitle', 'summary', 'og_image', 'og_description')
        ORDER BY e.edge_type, e.created_at
        "#,
    )
    .bind(params.article_id)
    .fetch_all(&state.db)
    .await
    .map_err(|e| {
        tracing::error!(error = %e, "Feil ved henting av presentasjonselementer");
        internal_error("Databasefeil ved henting av presentasjonselementer")
    })?;

    let elements: Vec<PresentationElement> = rows
        .into_iter()
        .map(|(node_id, edge_id, element_type, title, content, node_kind, metadata, edge_metadata, created_at)| {
            PresentationElement {
                node_id,
                edge_id,
                element_type,
                title,
                content,
                node_kind,
                metadata,
                edge_metadata,
                created_at,
            }
        })
        .collect();

    Ok(Json(QueryPresentationResponse {
        article_id: params.article_id,
        elements,
    }))
}

// =============================================================================
// GET /query/job_status — jobbstatus for polling fra frontend (oppgave 17.7)
// =============================================================================

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

#[derive(Serialize)]
pub struct JobStatusResponse {
    pub status: String,
    pub result: Option<serde_json::Value>,
    pub error_msg: Option<String>,
}

/// GET /query/job_status?job_id=...
///
/// Returnerer status, resultat og feilmelding for en jobb.
/// Brukes av frontend for å polle etter rendering-resultater.
pub async fn query_job_status(
    State(state): State<AppState>,
    _user: AuthUser,
    axum::extract::Query(params): axum::extract::Query<JobStatusParams>,
) -> Result<Json<JobStatusResponse>, Response> {
    let row = sqlx::query_as::<_, (String, Option<serde_json::Value>, Option<String>)>(
        r#"SELECT status::text, result, error_msg FROM job_queue WHERE id = $1"#,
    )
    .bind(params.job_id)
    .fetch_optional(&state.db)
    .await
    .map_err(|e| {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(ErrorResponse {
                error: format!("DB-feil: {e}"),
            }),
        )
            .into_response()
    })?;

    match row {
        Some((status, result, error_msg)) => Ok(Json(JobStatusResponse {
            status,
            result,
            error_msg,
        })),
        None => Err((
            StatusCode::NOT_FOUND,
            Json(ErrorResponse {
                error: "Jobb ikke funnet".to_string(),
            }),
        )
            .into_response()),
    }
}

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

    #[test]
    fn test_format_srt_timestamp() {
        assert_eq!(format_srt_timestamp(0), "00:00:00,000");
        assert_eq!(format_srt_timestamp(5230), "00:00:05,230");
        assert_eq!(format_srt_timestamp(83456), "00:01:23,456");
        assert_eq!(format_srt_timestamp(3_600_000), "01:00:00,000");
        assert_eq!(format_srt_timestamp(3_723_456), "01:02:03,456");
    }

    #[test]
    fn test_segments_to_srt() {
        let segments = vec![
            SegmentResult {
                id: 1,
                seq: 1,
                start_ms: 0,
                end_ms: 5230,
                content: "Hei og velkommen.".to_string(),
                edited: false,
            },
            SegmentResult {
                id: 2,
                seq: 2,
                start_ms: 5230,
                end_ms: 10500,
                content: "I dag snakker vi om fotball.".to_string(),
                edited: true,
            },
        ];

        let srt = segments_to_srt(&segments);
        let expected = "\
1
00:00:00,000 --> 00:00:05,230
Hei og velkommen.

2
00:00:05,230 --> 00:00:10,500
I dag snakker vi om fotball.
";
        assert_eq!(srt, expected);
    }

    #[test]
    fn test_segments_to_srt_single() {
        let segments = vec![SegmentResult {
            id: 1,
            seq: 1,
            start_ms: 0,
            end_ms: 3000,
            content: "Bare ett segment.".to_string(),
            edited: false,
        }];

        let srt = segments_to_srt(&segments);
        assert_eq!(srt, "1\n00:00:00,000 --> 00:00:03,000\nBare ett segment.\n");
    }
}
