//! Substrate view composition served by the daemon (ADR-046) — the accelerated
//! read path, shared by the HTTP API and the MCP surface so an interface IS a
//! mounted view (not bespoke code). Mirrors the deterministic fold of the
//! Nushell resolver (`reflection/modules/view.nu`): the declared prior ⊕ the
//! active adjustment chain. The Nushell path remains the source of truth and
//! the offline fallback (ADR-029). View files and the adjustment log are read
//! as JSON `Value` so the schema is not re-declared in Rust — only the fold is,
//! pinned by a unit test against the Nushell semantics.

use std::path::{Path, PathBuf};
use std::process::Command;

use axum::extract::{Path as PathExt, Query, State};
use axum::http::StatusCode;
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};

use crate::api::AppState;

fn views_dir(root: &Path) -> PathBuf {
    ontoref_ontology::layout::resolve_section(root, "reflection/views")
}

fn adjustments_path(root: &Path) -> PathBuf {
    root.join(".coder").join("view-adjustments.jsonl")
}

/// Export one view NCL file to a JSON `Value` (relative imports resolve from
/// the file's directory; the import path is supplied for any bare imports).
fn export_ncl(path: &Path, import_path: Option<&str>) -> Option<Value> {
    let mut cmd = Command::new("nickel");
    cmd.args(["export", "--format", "json"]).arg(path);
    if let Some(ip) = import_path {
        cmd.env("NICKEL_IMPORT_PATH", ip);
    }
    let out = cmd.output().ok()?;
    if !out.status.success() {
        return None;
    }
    serde_json::from_slice(&out.stdout).ok()
}

fn load_views(root: &Path, import_path: Option<&str>) -> Vec<Value> {
    let Ok(entries) = std::fs::read_dir(views_dir(root)) else {
        return Vec::new();
    };
    entries
        .flatten()
        .map(|e| e.path())
        .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("ncl"))
        .filter_map(|p| export_ncl(&p, import_path))
        .collect()
}

fn load_adjustments(root: &Path, view_id: &str) -> Vec<Value> {
    let Ok(text) = std::fs::read_to_string(adjustments_path(root)) else {
        return Vec::new();
    };
    text.lines()
        .filter(|l| !l.trim().is_empty())
        .filter_map(|l| serde_json::from_str::<Value>(l).ok())
        .filter(|e| e.get("view").and_then(Value::as_str) == Some(view_id))
        .collect()
}

/// Tune entries still in force: kind == "tune" whose seq is not reverted (C2).
fn active_tunes(adjustments: &[Value]) -> Vec<&Value> {
    let reverted: Vec<i64> = adjustments
        .iter()
        .filter(|a| a.get("kind").and_then(Value::as_str) == Some("revert"))
        .filter_map(|a| a.get("target_seq").and_then(Value::as_i64))
        .collect();
    adjustments
        .iter()
        .filter(|a| a.get("kind").and_then(Value::as_str).unwrap_or("tune") == "tune")
        .filter(|a| {
            a.get("seq")
                .and_then(Value::as_i64)
                .map(|s| !reverted.contains(&s))
                .unwrap_or(false)
        })
        .collect()
}

fn delta_for(active: &[&Value], level: &str) -> i64 {
    active
        .iter()
        .filter(|a| a.get("level").and_then(Value::as_str) == Some(level))
        .filter_map(|a| a.get("delta").and_then(Value::as_i64))
        .sum()
}

/// Compose the active recipe = declared prior ⊕ active adjustments (C1/C3).
/// Only 'Learned levels receive a delta; levels are ordered by tuned salience.
fn compose(view: &Value, adjustments: &[Value]) -> Value {
    let active = active_tunes(adjustments);
    let empty = Vec::new();
    let levels = view
        .get("levels")
        .and_then(Value::as_array)
        .unwrap_or(&empty);
    let mut composed: Vec<Value> = levels
        .iter()
        .map(|l| {
            let lref = l.get("ref").and_then(Value::as_str).unwrap_or("");
            let elig = l
                .get("eligibility")
                .and_then(Value::as_str)
                .unwrap_or("Always");
            let base = l.get("salience").and_then(Value::as_i64).unwrap_or(0);
            let d = if elig == "Learned" {
                delta_for(&active, lref)
            } else {
                0
            };
            json!({
                "ref": lref,
                "axis": l.get("axis").cloned().unwrap_or(Value::Null),
                "eligibility": elig,
                "persistence": l.get("persistence").cloned().unwrap_or(json!("Inline")),
                "verify": l.get("verify").cloned().unwrap_or(json!("Local")),
                "salience": base + d,
                "learned_delta": d,
            })
        })
        .collect();
    composed.sort_by(|a, b| {
        b.get("salience")
            .and_then(Value::as_i64)
            .unwrap_or(0)
            .cmp(&a.get("salience").and_then(Value::as_i64).unwrap_or(0))
    });
    let active_n = active.len();
    json!({
        "view": view.get("id").cloned().unwrap_or(Value::Null),
        "discipline": view.get("discipline").cloned().unwrap_or(Value::Null),
        "context": view.get("context").cloned().unwrap_or(json!("")),
        "recipe": if active_n > 0 { "tuned" } else { "prior" },
        "learns_from": view.get("learns_from").cloned().unwrap_or(Value::Null),
        "learning_active": active_n > 0,
        "adjustments_applied": active_n,
        "levels": composed,
        "actions": view.get("actions").cloned().unwrap_or(json!([])),
        "surfaces": view.get("surfaces").cloned().unwrap_or(json!([])),
    })
}

/// Why a discipline could not be mounted — mapped to HTTP status / MCP error by
/// callers.
pub enum MountError {
    NotFound(String),
    Multiple(Vec<String>),
}

/// Declared substrate views (id, discipline, context, learns_from) as JSON.
/// Shared by the HTTP `views_list` handler and the MCP `ontoref_view_list`
/// tool.
pub fn list_views_json(root: &Path, import_path: Option<&str>) -> Value {
    let rows: Vec<Value> = load_views(root, import_path)
        .iter()
        .map(|v| {
            json!({
                "id": v.get("id").cloned().unwrap_or(Value::Null),
                "discipline": v.get("discipline").cloned().unwrap_or(Value::Null),
                "context": v.get("context").cloned().unwrap_or(json!("")),
                "learns_from": v.get("learns_from").cloned().unwrap_or(Value::Null),
            })
        })
        .collect();
    json!({ "views": rows })
}

/// Compose the mounted recipe (prior ⊕ adjustments) for a discipline. Shared by
/// the HTTP `views_mount` handler and the MCP `ontoref_view_mount` tool.
pub fn compose_discipline(
    root: &Path,
    import_path: Option<&str>,
    discipline: &str,
) -> Result<Value, MountError> {
    let views = load_views(root, import_path);
    let matching: Vec<&Value> = views
        .iter()
        .filter(|v| v.get("discipline").and_then(Value::as_str) == Some(discipline))
        .collect();
    match matching.len() {
        0 => Err(MountError::NotFound(discipline.to_owned())),
        1 => {
            let view = matching[0];
            let view_id = view.get("id").and_then(Value::as_str).unwrap_or("");
            let adj = load_adjustments(root, view_id);
            Ok(compose(view, &adj))
        }
        _ => Err(MountError::Multiple(
            matching
                .iter()
                .filter_map(|v| v.get("context").and_then(Value::as_str))
                .map(str::to_owned)
                .collect(),
        )),
    }
}

#[derive(Deserialize, Default)]
pub struct SlugQuery {
    pub slug: Option<String>,
}

fn resolve_root_for_slug<'a>(
    state: &'a AppState,
    slug: Option<&str>,
) -> std::borrow::Cow<'a, std::path::Path> {
    if let Some(s) = slug {
        if let Some(ctx) = state.registry.get(s) {
            return std::borrow::Cow::Owned(ctx.root.clone());
        }
    }
    std::borrow::Cow::Borrowed(state.project_root.as_path())
}

fn resolve_import_path_for_slug(state: &AppState, slug: Option<&str>) -> Option<String> {
    if let Some(s) = slug {
        if let Some(ctx) = state.registry.get(s) {
            return ctx.import_path.clone();
        }
    }
    state.nickel_import_path.clone()
}

/// Declared substrate views. Accepts optional `?slug=` to query a registry
/// project's views.
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/api/views",
    auth = "none",
    actors = "agent, developer, ci, admin",
    tags = "views, substrate"
)]
pub async fn views_list(State(state): State<AppState>, Query(q): Query<SlugQuery>) -> Json<Value> {
    let root = resolve_root_for_slug(&state, q.slug.as_deref());
    let import_path = resolve_import_path_for_slug(&state, q.slug.as_deref());
    Json(list_views_json(&root, import_path.as_deref()))
}

/// Mount the composed recipe (prior ⊕ adjustments) for a discipline's view.
/// Accepts optional `?slug=` to mount from a registry project's view directory.
#[ontoref_derive::onto_api(
    method = "GET",
    path = "/api/views/{discipline}/mount",
    auth = "none",
    actors = "agent, developer, ci, admin",
    tags = "views, substrate"
)]
pub async fn views_mount(
    State(state): State<AppState>,
    PathExt(discipline): PathExt<String>,
    Query(q): Query<SlugQuery>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
    let root = resolve_root_for_slug(&state, q.slug.as_deref());
    let import_path = resolve_import_path_for_slug(&state, q.slug.as_deref());
    match compose_discipline(&root, import_path.as_deref(), &discipline) {
        Ok(v) => Ok(Json(v)),
        Err(MountError::NotFound(d)) => Err((
            StatusCode::NOT_FOUND,
            Json(json!({ "error": "no substrate view for discipline", "discipline": d })),
        )),
        Err(MountError::Multiple(contexts)) => Err((
            StatusCode::CONFLICT,
            Json(
                json!({ "error": "multiple views for discipline; specify context", "contexts": contexts }),
            ),
        )),
    }
}

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

    fn view() -> Value {
        json!({
            "id": "positioning-edit", "discipline": "positioning",
            "learns_from": "interactions:positioning",
            "levels": [
                { "ref": "positioning", "axis": "Reflection", "persistence": "Inline", "verify": "Local", "eligibility": "Learned", "salience": 5 },
                { "ref": "value-props", "axis": "Reflection", "persistence": "Inline", "verify": "Local", "eligibility": "Learned", "salience": 3 },
                { "ref": "ontology",    "axis": "Ontology",   "persistence": "Inline", "verify": "Local", "eligibility": "Always",  "salience": 0 }
            ],
            "actions": [], "surfaces": ["Cli"]
        })
    }

    #[test]
    fn fold_reorders_by_learned_delta_and_leaves_always_untouched() {
        let adj = vec![
            json!({ "kind": "tune", "seq": 1, "view": "positioning-edit", "level": "value-props", "delta": 4 }),
            json!({ "kind": "tune", "seq": 2, "view": "positioning-edit", "level": "positioning", "delta": 1 }),
        ];
        let r = compose(&view(), &adj);
        let levels = r["levels"].as_array().unwrap();
        assert_eq!(levels[0]["ref"], "value-props");
        assert_eq!(levels[0]["salience"], 7);
        assert_eq!(levels[1]["ref"], "positioning");
        assert_eq!(levels[1]["salience"], 6);
        let onto = levels.iter().find(|l| l["ref"] == "ontology").unwrap();
        assert_eq!(onto["salience"], 0);
        assert_eq!(onto["learned_delta"], 0);
        assert_eq!(r["recipe"], "tuned");
        assert_eq!(r["adjustments_applied"], 2);
    }

    #[test]
    fn reverted_tune_drops_out_of_the_fold() {
        let adj = vec![
            json!({ "kind": "tune", "seq": 1, "view": "positioning-edit", "level": "value-props", "delta": 4 }),
            json!({ "kind": "revert", "seq": 2, "view": "positioning-edit", "target_seq": 1 }),
        ];
        let r = compose(&view(), &adj);
        let levels = r["levels"].as_array().unwrap();
        let vp = levels.iter().find(|l| l["ref"] == "value-props").unwrap();
        assert_eq!(vp["salience"], 3);
        assert_eq!(r["recipe"], "prior");
        assert_eq!(r["adjustments_applied"], 0);
    }
}
