// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2024-2026 SuperNovae Studio <contact@supernovae.studio>

//! Static jq COMPILE-check (the deep-static tier · spec `07-conformance.md`
//! §Levels · `NIKA-VAR-005` « static expression violation — … jq compile
//! error »).
//!
//! A `nika:jq` `expression:` (and a `nika:fetch` `mode: jq` `jq:`) is compiled
//! with the SAME jaq stack the runtime `nika:jq` builtin uses (`nika-builtin`
//! `data::jq`): `jaq_core` + `jaq_std` + `jaq_json` at one workspace-pinned
//! version, the SAME `defs`/`funs` chain, so a program the checker accepts the
//! runtime also accepts — the parity is structural (one jaq, one config), not
//! a re-implementation. The layering forbids sharing the engine itself
//! (`nika-schema` is L0 · the runtime jq is L2 `nika-builtin`), so the compile
//! call + its clean-error renderer are mirrored in both — exactly like the net
//! `url_host` split (the layer cannot host a shared std jaq below both).
//!
//! Only a LITERAL program is checked; a `${{ }}`-templated one is built at
//! run time and stays the runtime `NIKA-BUILTIN-JQ-001` check.
//!
//! The diagnostic is a CLEAN one-line reason (NOT the raw jaq `Debug` repr the
//! runtime used to leak · the jq-3 finding) — `Expect::as_str` + the offending
//! source slice.

use jaq_core::Compiler;
use jaq_core::data::JustLut;
use jaq_core::load::{Arena, Error, File, Loader};
use jaq_json::Val;

use crate::error::SchemaError;
use crate::raw::{RawAction, RawWorkflow};

/// Compile-check a jq program with the runtime's exact jaq stack. `Ok(())`
/// when it compiles; `Err(reason)` with a clean one-line message otherwise.
pub(super) fn jq_compiles(program: &str) -> Result<(), String> {
    let defs = jaq_core::defs()
        .chain(jaq_std::defs())
        .chain(jaq_json::defs());
    let funs = jaq_core::funs::<JustLut<Val>>()
        .chain(jaq_std::funs())
        .chain(jaq_json::funs());
    let arena = Arena::default();
    let modules = Loader::new(defs)
        .load(
            &arena,
            File {
                code: program,
                path: (),
            },
        )
        .map_err(|errs| render_load(&errs))?;
    Compiler::default()
        .with_funs(funs)
        .compile(modules)
        .map(|_| ())
        .map_err(|errs| render_compile(&errs))
}

/// Render a jaq LOAD error set (lex/parse/io) as one clean line.
fn render_load(errs: &[(File<&str, ()>, Error<&str>)]) -> String {
    let Some((_, first)) = errs.first() else {
        return "does not parse".to_owned();
    };
    match first {
        Error::Io(v) => v
            .first()
            .map_or_else(|| "io error".to_owned(), |(_, m)| format!("io: {m}")),
        Error::Lex(v) => v.first().map_or_else(
            || "lexing error".to_owned(),
            |(exp, at)| syntax_msg(exp.as_str(), at),
        ),
        Error::Parse(v) => v.first().map_or_else(
            || "parse error".to_owned(),
            |(exp, at)| syntax_msg(exp.as_str(), at),
        ),
    }
}

/// Render a jaq COMPILE error set (undefined filters/variables) as one line.
#[allow(clippy::type_complexity)] // the shape is jaq's `compile::Errors`, not ours
fn render_compile<U>(errs: &[(File<&str, ()>, Vec<(&str, U)>)]) -> String {
    errs.first().and_then(|(_, v)| v.first()).map_or_else(
        || "compile error".to_owned(),
        |(name, _)| format!("undefined filter or variable `{name}`"),
    )
}

/// One clean « expected X near Y » line from an `Expect::as_str` + slice.
fn syntax_msg(expected: &str, at: &str) -> String {
    let at = at.trim();
    if at.is_empty() {
        format!("expected {expected} (unexpected end of input)")
    } else {
        let snippet: String = at.chars().take(24).collect();
        format!("expected {expected} near `{snippet}`")
    }
}

/// Scan every LITERAL jq program in the workflow (`nika:jq` `expression:` +
/// `nika:fetch` `mode: jq` `jq:`) and compile-check it.
pub(super) fn scan_jq(wf: &RawWorkflow, errors: &mut Vec<SchemaError>) {
    for task in &wf.tasks {
        check_action(&task.value.action, errors);
        for cleanup in &task.value.on_finally {
            check_action(&cleanup.value.action, errors);
        }
    }
}

fn check_action(action: &RawAction, errors: &mut Vec<SchemaError>) {
    let RawAction::Invoke(invoke) = action else {
        return;
    };
    let tool = invoke.tool.value.as_str();
    let span = invoke.tool.span;
    let Some(args) = invoke.args.as_ref().map(|a| &a.value) else {
        return;
    };
    // The jq program lives under `expression` for nika:jq, `jq` for a
    // nika:fetch `mode: jq`.
    let key = match tool {
        "nika:jq" => "expression",
        "nika:fetch" => "jq",
        _ => return,
    };
    let Some(program) = args.get(key).and_then(serde_json::Value::as_str) else {
        return;
    };
    // A templated program is built at run time — the runtime check owns it.
    if program.contains("${{") {
        return;
    }
    if let Err(reason) = jq_compiles(program) {
        errors.push(SchemaError::ExpressionViolation {
            reason: format!("jq compile error — {reason}"),
            span: Some(span),
        });
    }
}

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

    #[test]
    fn valid_jq_compiles() {
        for ok in [
            ".",
            ".a.b",
            "[.items[].name]",
            ".x | length",
            "map(.n)",
            "to_entries",
        ] {
            assert!(jq_compiles(ok).is_ok(), "`{ok}` should compile");
        }
    }

    #[test]
    fn malformed_jq_is_a_clean_error_not_a_debug_repr() {
        // The 006-jq-compile-error fixture shape + the jq-3 leak class.
        let err = jq_compiles(".foo | | bad").expect_err("trailing pipe");
        assert!(
            !err.contains("File {"),
            "no raw jaq Debug repr leaks: {err}"
        );
        assert!(
            !err.contains("Parse(["),
            "no raw jaq Debug repr leaks: {err}"
        );
        assert!(
            jq_compiles(".a |").is_err(),
            "incomplete pipe is a compile error"
        );
    }

    #[test]
    fn unknown_filter_is_an_undefined_error() {
        // `matches` is not in the v0.1 jaq surface — a compile (undefined) error.
        let err = jq_compiles(".s | nonexistent_filter_xyz").expect_err("undefined");
        assert!(err.contains("undefined"), "{err}");
    }
}
