//! §9.4 Service-interceptor markers — the `interceptors` table (0073).
//!
//! A marker `(owner, service, op) -> interceptor_script` declares a before-op
//! allow/deny hook. Pure declaration (like `extension_points`, 0051); the
//! interceptor's behaviour is a normal script resolved + run through `invoke()`
//! re-entry. This module holds the read + transactional-write helpers (free
//! functions over `&PgPool` / `&mut Transaction`, keyed by [`ScriptOwner`]),
//! plus the runtime [`resolve_chain`] walk (all markers on the app's chain).

use chrono::{DateTime, Utc};
use picloud_shared::{AppId, ScriptOwner};
use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;

use crate::config_resolver::CHAIN_LEVELS_CTE;

/// One interceptor marker at an owner: which `(service, op)` it guards and the
/// interceptor script name.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InterceptorMarker {
    pub service: String,
    pub op: String,
    /// `before` (allow/deny + transform) or `after` (observe; §9.4 M3).
    pub phase: String,
    pub script: String,
    /// §9.4 M5: per-hook timeout, or `None` for the instance default.
    pub timeout_ms: Option<i32>,
}

/// List the markers declared **directly at** `owner` (not inherited), ordered
/// deterministically. Used by `load_current` (apply diff) and `interceptors ls`.
pub async fn list_for_owner(
    pool: &PgPool,
    owner: ScriptOwner,
) -> Result<Vec<InterceptorMarker>, sqlx::Error> {
    let rows: Vec<(String, String, String, String, Option<i32>)> = match owner {
        ScriptOwner::App(a) => {
            sqlx::query_as(
                "SELECT service, op, phase, interceptor_script, timeout_ms FROM interceptors \
                 WHERE app_id = $1 ORDER BY service, op, phase",
            )
            .bind(a.into_inner())
            .fetch_all(pool)
            .await?
        }
        ScriptOwner::Group(g) => {
            sqlx::query_as(
                "SELECT service, op, phase, interceptor_script, timeout_ms FROM interceptors \
                 WHERE group_id = $1 ORDER BY service, op, phase",
            )
            .bind(g.into_inner())
            .fetch_all(pool)
            .await?
        }
    };
    Ok(rows
        .into_iter()
        .map(
            |(service, op, phase, script, timeout_ms)| InterceptorMarker {
                service,
                op,
                phase,
                script,
                timeout_ms,
            },
        )
        .collect())
}

/// All markers **visible to an app** — declared at the app or any ancestor
/// group (each `(service, op)` resolved nearest-owner-wins). Used by
/// `interceptors ls --app` and by the resolver's chain view.
pub async fn list_on_app_chain(
    pool: &PgPool,
    app_id: AppId,
) -> Result<Vec<InterceptorMarker>, sqlx::Error> {
    let rows: Vec<(String, String, String, String, Option<i32>)> = sqlx::query_as(&format!(
        "{CHAIN_LEVELS_CTE} \
         SELECT DISTINCT ON (i.service, i.op, i.phase) \
                i.service, i.op, i.phase, i.interceptor_script, i.timeout_ms \
         FROM chain c \
         JOIN interceptors i ON (i.app_id = c.app_owner OR i.group_id = c.group_owner) \
         ORDER BY i.service, i.op, i.phase, c.depth ASC",
    ))
    .bind(app_id.into_inner())
    .fetch_all(pool)
    .await?;
    Ok(rows
        .into_iter()
        .map(
            |(service, op, phase, script, timeout_ms)| InterceptorMarker {
                service,
                op,
                phase,
                script,
                timeout_ms,
            },
        )
        .collect())
}

/// The nearest interceptor marker on an app's chain, with its script resolved
/// **at the declaring owner** (the seal). `script` is `None` when the marker
/// names a script that is missing or disabled at that owner — a dangling hook
/// the caller must fail closed on.
#[derive(Debug, Clone)]
pub struct SealedInterceptor {
    pub marker_app: Option<Uuid>,
    pub marker_group: Option<Uuid>,
    pub script_name: String,
    /// §9.4 M5: per-hook wall-clock timeout, or `None` for the instance default.
    pub timeout_ms: Option<i32>,
    /// `(script_id, source, updated_at)` of the sealed script, or `None` if it
    /// is missing/disabled at the declaring owner.
    pub script: Option<(Uuid, String, DateTime<Utc>)>,
}

impl SealedInterceptor {
    /// The owner that DECLARED the marker (and therefore owns the resolved
    /// script) — the lexical origin for the interceptor's own `import`s (§5.5).
    #[must_use]
    pub fn sealing_owner(&self) -> ScriptOwner {
        if let Some(g) = self.marker_group {
            ScriptOwner::Group(g.into())
        } else {
            ScriptOwner::App(
                self.marker_app
                    .expect("marker XOR: app when not group")
                    .into(),
            )
        }
    }
}

/// The ordered before + after interceptors guarding a `(service, op)` for a
/// calling app, each sealed to its declaring owner (see [`SealedInterceptor`]).
#[derive(Debug, Clone, Default)]
pub struct SealedChain {
    /// Ordered ancestor→app (outermost/group guard first).
    pub before: Vec<SealedInterceptor>,
    /// Ordered app→ancestor.
    pub after: Vec<SealedInterceptor>,
}

/// Resolve ALL interceptor markers guarding `(service, op)` for a calling app —
/// every marker visible on the app's chain (app + each ancestor group), each
/// with its script **sealed to the owner that declared the marker** (a
/// descendant app cannot shadow it with a same-named local script). Split by
/// `phase` and ordered: `before` ancestor→app (an outer/group guard runs first,
/// so a group denial cannot be bypassed by a descendant), `after` app→ancestor.
/// **This chain walk is the resolution boundary** — a sibling-subtree app never
/// sees another subtree's interceptor. The script join is `LEFT` so a marker
/// whose script is missing/disabled still returns an entry (with `script:
/// None`) the caller fails closed on, rather than being silently dropped.
///
/// Per the `(owner, service, op, phase)` partial-unique index there is at most
/// one marker per owner per phase, so no per-owner dedup is needed.
pub async fn resolve_chain(
    pool: &PgPool,
    app_id: AppId,
    service: &str,
    op: &str,
) -> Result<SealedChain, sqlx::Error> {
    #[allow(clippy::type_complexity)]
    let rows: Vec<(
        Option<Uuid>,          // marker_app
        Option<Uuid>,          // marker_group
        String,                // script_name
        String,                // phase
        Option<i32>,           // timeout_ms
        i32,                   // depth (0 = app, larger = ancestor)
        Option<Uuid>,          // script id
        Option<String>,        // script source
        Option<DateTime<Utc>>, // script updated_at
    )> = sqlx::query_as(&format!(
        "{CHAIN_LEVELS_CTE}, \
         markers AS ( \
             SELECT i.interceptor_script AS script_name, \
                    i.app_id AS marker_app, i.group_id AS marker_group, \
                    i.phase AS phase, i.timeout_ms AS timeout_ms, c.depth AS depth \
             FROM chain c \
             JOIN interceptors i ON (i.app_id = c.app_owner OR i.group_id = c.group_owner) \
             WHERE i.service = $2 AND i.op = $3 \
         ) \
         SELECT m.marker_app, m.marker_group, m.script_name, m.phase, m.timeout_ms, m.depth, \
                s.id, s.source, s.updated_at \
         FROM markers m \
         LEFT JOIN scripts s ON ( \
             (m.marker_app IS NOT NULL AND s.app_id = m.marker_app \
              AND s.name = m.script_name AND s.enabled) \
             OR (m.marker_group IS NOT NULL AND s.group_id = m.marker_group \
              AND s.name = m.script_name AND s.enabled) \
         )",
    ))
    .bind(app_id.into_inner())
    .bind(service)
    .bind(op)
    .fetch_all(pool)
    .await?;

    // Split by phase and order: `before` ancestor→app = depth DESC (larger
    // depth = ancestor runs first); `after` app→ancestor = depth ASC.
    let mut before: Vec<(i32, SealedInterceptor)> = Vec::new();
    let mut after: Vec<(i32, SealedInterceptor)> = Vec::new();
    for (marker_app, marker_group, script_name, phase, timeout_ms, depth, sid, src, upd) in rows {
        let sealed = SealedInterceptor {
            marker_app,
            marker_group,
            script_name,
            timeout_ms,
            script: match (sid, src, upd) {
                (Some(id), Some(source), Some(updated_at)) => Some((id, source, updated_at)),
                _ => None,
            },
        };
        if phase == "after" {
            after.push((depth, sealed));
        } else {
            before.push((depth, sealed));
        }
    }
    before.sort_by(|a, b| b.0.cmp(&a.0)); // depth DESC → ancestor first
    after.sort_by(|a, b| a.0.cmp(&b.0)); // depth ASC → app first
    Ok(SealedChain {
        before: before.into_iter().map(|(_, s)| s).collect(),
        after: after.into_iter().map(|(_, s)| s).collect(),
    })
}

/// Upsert a marker at `owner` in the apply transaction. A re-apply that changes
/// only the interceptor script updates it in place (no version churn on the
/// `(service, op)` identity).
pub async fn insert_interceptor_tx(
    tx: &mut Transaction<'_, Postgres>,
    owner: ScriptOwner,
    service: &str,
    op: &str,
    phase: &str,
    script: &str,
    timeout_ms: Option<i32>,
) -> Result<(), sqlx::Error> {
    match owner {
        ScriptOwner::App(a) => {
            sqlx::query(
                // The conflict arbiter includes `phase` to match the
                // per-(owner, service, op, phase) index (§9.4 M3). A re-apply
                // that changes only the script or timeout updates in place.
                "INSERT INTO interceptors (app_id, service, op, phase, interceptor_script, timeout_ms) \
                 VALUES ($1, $2, $3, $4, $5, $6) \
                 ON CONFLICT (app_id, service, op, phase) WHERE app_id IS NOT NULL \
                 DO UPDATE SET interceptor_script = EXCLUDED.interceptor_script, \
                               timeout_ms = EXCLUDED.timeout_ms, updated_at = NOW()",
            )
            .bind(a.into_inner())
            .bind(service)
            .bind(op)
            .bind(phase)
            .bind(script)
            .bind(timeout_ms)
            .execute(&mut **tx)
            .await?;
        }
        ScriptOwner::Group(g) => {
            sqlx::query(
                "INSERT INTO interceptors (group_id, service, op, phase, interceptor_script, timeout_ms) \
                 VALUES ($1, $2, $3, $4, $5, $6) \
                 ON CONFLICT (group_id, service, op, phase) WHERE group_id IS NOT NULL \
                 DO UPDATE SET interceptor_script = EXCLUDED.interceptor_script, \
                               timeout_ms = EXCLUDED.timeout_ms, updated_at = NOW()",
            )
            .bind(g.into_inner())
            .bind(service)
            .bind(op)
            .bind(phase)
            .bind(script)
            .bind(timeout_ms)
            .execute(&mut **tx)
            .await?;
        }
    }
    Ok(())
}

/// Delete a marker at `owner` (by `(service, op, phase)`), in the apply
/// transaction. Used by `--prune` when the manifest stops declaring it.
pub async fn delete_interceptor_tx(
    tx: &mut Transaction<'_, Postgres>,
    owner: ScriptOwner,
    service: &str,
    op: &str,
    phase: &str,
) -> Result<(), sqlx::Error> {
    match owner {
        ScriptOwner::App(a) => {
            sqlx::query(
                "DELETE FROM interceptors \
                 WHERE app_id = $1 AND service = $2 AND op = $3 AND phase = $4",
            )
            .bind(a.into_inner())
            .bind(service)
            .bind(op)
            .bind(phase)
            .execute(&mut **tx)
            .await?;
        }
        ScriptOwner::Group(g) => {
            sqlx::query(
                "DELETE FROM interceptors \
                 WHERE group_id = $1 AND service = $2 AND op = $3 AND phase = $4",
            )
            .bind(g.into_inner())
            .bind(service)
            .bind(op)
            .bind(phase)
            .execute(&mut **tx)
            .await?;
        }
    }
    Ok(())
}
