//! OSCAL 1.1 catalog types + mapping into the controls corpus.
//!
//! Deserialises the OSCAL catalog served by breakpilot-compliance
//! (`GET /api/compliance/v1/oscal/catalog`) and maps its controls into the
//! framework-agnostic [`crate::traits::Control`] that the mapping engine consumes.
//! Only the fields we use are modelled; unknown OSCAL fields are ignored so the
//! producer can add detail without breaking us.
//!
//! Scope boundary: this is the *catalog* (domain content). Assessment objectives
//! and scanner routing live in our assessment layer, not here — see
//! [`crate::traits::ControlsProvider`].

use serde::Deserialize;

use crate::models::onboarding::ComplianceFramework;
use crate::traits::Control as CorpusControl;

/// A parsed OSCAL catalog document (`{"catalog": {...}}`).
#[derive(Debug, Clone, Deserialize)]
pub struct OscalDocument {
    pub catalog: Catalog,
}

/// An OSCAL catalog: metadata + a tree of control groups.
#[derive(Debug, Clone, Deserialize)]
pub struct Catalog {
    pub uuid: String,
    pub metadata: Metadata,
    #[serde(default)]
    pub groups: Vec<Group>,
    #[serde(rename = "back-matter", default)]
    pub back_matter: Option<BackMatter>,
}

/// Catalog metadata (title/version + provenance props).
#[derive(Debug, Clone, Deserialize)]
pub struct Metadata {
    pub title: String,
    pub version: String,
    #[serde(rename = "oscal-version")]
    pub oscal_version: String,
    #[serde(default)]
    pub props: Vec<Prop>,
}

/// A name/value property, optionally namespaced.
#[derive(Debug, Clone, Deserialize)]
pub struct Prop {
    pub name: String,
    pub value: String,
    #[serde(default)]
    pub ns: Option<String>,
}

/// A control group (may nest sub-groups and controls).
#[derive(Debug, Clone, Deserialize)]
pub struct Group {
    #[serde(default)]
    pub id: String,
    #[serde(default)]
    pub title: String,
    #[serde(default)]
    pub controls: Vec<Control>,
    #[serde(default)]
    pub groups: Vec<Group>,
}

/// An OSCAL control (may nest enhancement controls).
#[derive(Debug, Clone, Deserialize)]
pub struct Control {
    pub id: String,
    #[serde(default)]
    pub title: String,
    #[serde(default)]
    pub props: Vec<Prop>,
    #[serde(default)]
    pub parts: Vec<Part>,
    #[serde(default)]
    pub links: Vec<Link>,
    #[serde(default)]
    pub controls: Vec<Control>,
}

/// A control part (e.g. the `statement`), may nest sub-parts.
#[derive(Debug, Clone, Deserialize)]
pub struct Part {
    #[serde(default)]
    pub name: String,
    #[serde(default)]
    pub prose: Option<String>,
    #[serde(default)]
    pub parts: Vec<Part>,
}

/// A link, e.g. a `reference` to a back-matter resource.
#[derive(Debug, Clone, Deserialize)]
pub struct Link {
    pub href: String,
    #[serde(default)]
    pub rel: Option<String>,
}

/// Back-matter holding referenced resources (e.g. the CRA measures).
#[derive(Debug, Clone, Deserialize)]
pub struct BackMatter {
    #[serde(default)]
    pub resources: Vec<Resource>,
}

/// A back-matter resource referenced by control links.
#[derive(Debug, Clone, Deserialize)]
pub struct Resource {
    pub uuid: String,
    #[serde(default)]
    pub title: Option<String>,
    #[serde(default)]
    pub description: Option<String>,
}

impl Metadata {
    /// First prop value with the given name.
    pub fn prop(&self, name: &str) -> Option<&str> {
        self.props
            .iter()
            .find(|p| p.name == name)
            .map(|p| p.value.as_str())
    }
}

impl Control {
    /// First prop value with the given name.
    pub fn prop(&self, name: &str) -> Option<&str> {
        self.props
            .iter()
            .find(|p| p.name == name)
            .map(|p| p.value.as_str())
    }

    /// The control's `statement` prose, if present.
    pub fn statement(&self) -> Option<&str> {
        self.parts
            .iter()
            .find(|p| p.name == "statement")
            .and_then(|p| p.prose.as_deref())
    }
}

impl OscalDocument {
    /// The framework this catalog declares (`metadata.props[name="framework"]`).
    pub fn framework(&self) -> Option<ComplianceFramework> {
        framework_from_str(self.catalog.metadata.prop("framework")?)
    }

    /// The catalog `content-hash` prop — consumers pin this to snapshot/detect drift.
    pub fn content_hash(&self) -> Option<&str> {
        self.catalog.metadata.prop("content-hash")
    }

    /// Flatten the catalog into the corpus controls the mapping engine consumes.
    pub fn to_controls(&self) -> Vec<CorpusControl> {
        let framework = self.framework().unwrap_or(ComplianceFramework::Cra);
        let source_label = self.catalog.metadata.title.as_str();
        let mut out = Vec::new();
        for group in &self.catalog.groups {
            collect_group(group, framework, source_label, &mut out);
        }
        out
    }
}

/// Map an OSCAL framework token (e.g. `"cra"`) to [`ComplianceFramework`] via its
/// serde snake_case representation.
fn framework_from_str(raw: &str) -> Option<ComplianceFramework> {
    serde_json::from_value(serde_json::Value::String(raw.to_string())).ok()
}

fn collect_group(
    group: &Group,
    framework: ComplianceFramework,
    source_label: &str,
    out: &mut Vec<CorpusControl>,
) {
    for control in &group.controls {
        collect_control(control, framework, source_label, out);
    }
    for sub in &group.groups {
        collect_group(sub, framework, source_label, out);
    }
}

fn collect_control(
    control: &Control,
    framework: ComplianceFramework,
    source_label: &str,
    out: &mut Vec<CorpusControl>,
) {
    let source = match control.prop("annex-anchor") {
        Some(anchor) => Some(format!("{source_label} · {anchor}")),
        None => Some(source_label.to_string()),
    };
    out.push(CorpusControl {
        id: control.id.clone(),
        framework,
        title: control.title.clone(),
        text: control.statement().unwrap_or_default().to_string(),
        source,
    });
    for enhancement in &control.controls {
        collect_control(enhancement, framework, source_label, out);
    }
}

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

    const CATALOG: &str = include_str!("../../tests/data/cra_catalog.json");

    fn parse() -> OscalDocument {
        serde_json::from_str(CATALOG).unwrap()
    }

    #[test]
    fn parses_full_catalog() {
        let doc = parse();
        assert_eq!(doc.catalog.metadata.oscal_version, "1.1.2");
        assert!(!doc.catalog.groups.is_empty());
        assert!(doc.catalog.back_matter.is_some());
    }

    #[test]
    fn maps_all_controls_to_corpus() {
        let doc = parse();
        let controls = doc.to_controls();
        assert_eq!(controls.len(), 40);
        assert_eq!(doc.framework(), Some(ComplianceFramework::Cra));

        let c8 = controls.iter().find(|c| c.id == "cra-ai-8").unwrap();
        assert_eq!(c8.framework, ComplianceFramework::Cra);
        assert!(!c8.title.is_empty());
        assert!(!c8.text.is_empty(), "statement prose should map into text");
        assert!(c8.source.as_deref().unwrap_or_default().contains("Annex I"));
    }

    #[test]
    fn exposes_content_hash_for_snapshotting() {
        assert_eq!(parse().content_hash().map(str::len), Some(64));
    }
}
