//! Curated demo targets (#187).
//!
//! `fixtures/demo-targets/targets.json` is the versioned, reproducible set of
//! representative targets every scan path can be exercised against: PlcSps
//! (composite), a plain git SAST repo, a WebApp (git + live URL) and an RTOS
//! firmware repo. The manifest is consumed by:
//!
//! * `scripts/seed-demo-targets.sh` — onboards the targets through the
//!   public API (optionally triggering a first scan),
//! * the nightly regression (#188) — the `expect` block is the golden
//!   baseline per target,
//! * the lib tests below — which keep the manifest well-formed and assert the
//!   PLC baselines offline (no Mongo, no network) on every CI run.
//!
//! Artifacts come in two flavours: `source_ref` (git URL / live URL / image
//! ref; may be overridden by the env var named in `source_ref_env`) and
//! `upload` (a file path relative to the workspace root, pushed through
//! `POST /targets/{id}/artifacts/upload`; may instead come from the env var
//! named in `upload_env`). Artifacts flagged `optional` are skipped when their
//! env var is unset, so the set seeds cleanly on a laptop without OT infra.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use compliance_core::models::onboarding::{ArtifactKind, PlcFormat, TargetType};
use serde::{Deserialize, Serialize};

/// Manifest path, relative to the workspace root.
pub const MANIFEST_PATH: &str = "fixtures/demo-targets/targets.json";

/// Why a manifest could not be loaded.
#[derive(Debug, thiserror::Error)]
pub enum FixtureError {
    #[error("read {path}: {source}")]
    Io {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },
    #[error("parse {path}: {source}")]
    Parse {
        path: PathBuf,
        #[source]
        source: serde_json::Error,
    },
}

/// The whole demo-target set.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DemoTargets {
    /// Bumped on incompatible manifest changes.
    pub schema_version: u32,
    /// Prepended to every target name on seed; the seed script's `--reset`
    /// deletes exactly the targets carrying this prefix.
    pub name_prefix: String,
    pub targets: Vec<DemoTarget>,
}

/// One curated target.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DemoTarget {
    /// Stable machine key (used in baseline reports and as the default
    /// `repo_id`-ish handle in nightly output).
    pub key: String,
    /// Human name (without the prefix).
    pub name: String,
    pub target_type: TargetType,
    #[serde(default)]
    pub description: Option<String>,
    #[serde(default)]
    pub artifacts: Vec<DemoArtifact>,
    /// Golden baseline for the nightly regression.
    #[serde(default)]
    pub expect: Expect,
}

/// One artifact of a curated target.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DemoArtifact {
    pub kind: ArtifactKind,
    /// Literal reference (git URL, live URL, image ref) — the default when
    /// `source_ref_env` is unset in the environment.
    #[serde(default)]
    pub source_ref: Option<String>,
    /// Env var that overrides `source_ref` at seed time.
    #[serde(default)]
    pub source_ref_env: Option<String>,
    /// Workspace-relative file to upload as this artifact's content.
    #[serde(default)]
    pub upload: Option<String>,
    /// Env var holding an absolute path to upload instead of `upload`.
    #[serde(default)]
    pub upload_env: Option<String>,
    #[serde(default)]
    pub branch: Option<String>,
    /// Commit the baseline was recorded against (informational: the agent
    /// clones `branch`; re-pin when the baseline moves).
    #[serde(default)]
    pub pin: Option<String>,
    #[serde(default)]
    pub pin_tag: Option<String>,
    #[serde(default)]
    pub plc_format: Option<PlcFormat>,
    /// Skip silently when the env var is unset (needs infra not every
    /// environment has).
    #[serde(default)]
    pub optional: bool,
}

impl DemoArtifact {
    /// The upload path, resolved against the workspace root. `None` for
    /// reference-style artifacts or env-only uploads whose var is unset.
    pub fn upload_path(&self, root: &Path) -> Option<PathBuf> {
        if let Some(var) = &self.upload_env {
            if let Ok(p) = std::env::var(var) {
                if !p.is_empty() {
                    return Some(PathBuf::from(p));
                }
            }
        }
        self.upload.as_ref().map(|p| root.join(p))
    }

    /// True when this artifact only exists if its env var is provided.
    pub fn env_only(&self) -> bool {
        self.upload.is_none() && self.source_ref.is_none()
    }
}

/// Golden baseline; every field is optional so a target can assert only what
/// is deterministic for it.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Expect {
    /// Lower bound on total findings after a full scan.
    #[serde(default)]
    pub min_findings: Option<usize>,
    /// Rule ids that must be present among SAST findings.
    #[serde(default)]
    pub sast_rule_ids: Vec<String>,
    /// CWE ids (`CWE-NNN`) that must be present.
    #[serde(default)]
    pub cwes: Vec<String>,
    /// Control refs (e.g. `cra-ai-8`) that must be stamped on some finding.
    #[serde(default)]
    pub control_refs: Vec<String>,
    /// Lower bound on SBOM components.
    #[serde(default)]
    pub min_sbom_components: Option<usize>,
    /// Scans `applicable-scans` must offer for this target.
    #[serde(default)]
    pub scans_offered: Vec<String>,
    #[serde(default)]
    pub pentest_supported: Option<bool>,
    /// `key -> value` facts `detect` must surface.
    #[serde(default)]
    pub detected_facts: BTreeMap<String, String>,
}

impl DemoTargets {
    /// Workspace root, derived from this crate's manifest dir.
    pub fn workspace_root() -> PathBuf {
        Path::new(env!("CARGO_MANIFEST_DIR"))
            .parent()
            .map(Path::to_path_buf)
            .unwrap_or_else(|| PathBuf::from("."))
    }

    /// Load the checked-in manifest.
    pub fn load() -> Result<Self, FixtureError> {
        Self::load_from(&Self::workspace_root().join(MANIFEST_PATH))
    }

    /// Load a manifest from an explicit path.
    pub fn load_from(path: &Path) -> Result<Self, FixtureError> {
        let raw = std::fs::read_to_string(path).map_err(|source| FixtureError::Io {
            path: path.to_path_buf(),
            source,
        })?;
        serde_json::from_str(&raw).map_err(|source| FixtureError::Parse {
            path: path.to_path_buf(),
            source,
        })
    }

    /// Display name as the seed script creates it.
    pub fn full_name(&self, t: &DemoTarget) -> String {
        format!("{}{}", self.name_prefix, t.name)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::pipeline::plc::analyze_tree;
    use std::collections::{BTreeSet, HashSet};

    fn manifest() -> DemoTargets {
        DemoTargets::load().expect("demo manifest loads")
    }

    #[test]
    fn manifest_is_well_formed() {
        let m = manifest();
        let root = DemoTargets::workspace_root();
        assert_eq!(m.schema_version, 1);
        assert!(!m.targets.is_empty());

        let mut keys = HashSet::new();
        for t in &m.targets {
            assert!(keys.insert(t.key.as_str()), "duplicate key {}", t.key);
            assert!(!t.artifacts.is_empty(), "{}: needs artifacts", t.key);
            for a in &t.artifacts {
                let refs = usize::from(a.source_ref.is_some()) + usize::from(a.upload.is_some());
                let env_only = a.env_only();
                assert!(
                    refs == 1 || (env_only && a.optional),
                    "{}: artifact {:?} must have exactly one of source_ref/upload, \
                     or be optional + env-only",
                    t.key,
                    a.kind
                );
                if let Some(p) = &a.upload {
                    assert!(root.join(p).is_file(), "{}: upload {p} missing", t.key);
                }
                match a.kind {
                    ArtifactKind::PlcProject => {
                        assert!(
                            a.plc_format.is_some(),
                            "{}: PLC upload needs plc_format",
                            t.key
                        );
                    }
                    ArtifactKind::GitRepo => {
                        assert!(a.branch.is_some(), "{}: git artifact needs branch", t.key);
                        assert!(a.pin.is_some(), "{}: git artifact needs a pin", t.key);
                    }
                    _ => {}
                }
            }
        }

        // Coverage the story asks for: PlcSps, firmware, web app, plain git.
        let types: HashSet<TargetType> = m.targets.iter().map(|t| t.target_type).collect();
        for want in [
            TargetType::PlcSps,
            TargetType::FirmwareRtos,
            TargetType::WebApp,
            TargetType::BackendService,
        ] {
            assert!(types.contains(&want), "manifest lacks a {want:?} target");
        }
    }

    /// Offline golden baseline: the checked-in PLC fixtures must keep producing
    /// the rule ids the manifest promises. Runs the real control-logic
    /// analyzer over the fixture files.
    #[test]
    fn plc_fixtures_meet_golden_baseline() {
        let m = manifest();
        let root = DemoTargets::workspace_root();
        let all = analyze_tree(&root.join("examples/plc-demo"), "demo");

        for t in m
            .targets
            .iter()
            .filter(|t| t.target_type == TargetType::PlcSps)
        {
            let files: Vec<String> = t
                .artifacts
                .iter()
                .filter(|a| a.kind == ArtifactKind::PlcProject)
                .filter_map(|a| a.upload.as_deref())
                .filter_map(|p| Path::new(p).file_name())
                .map(|n| n.to_string_lossy().into_owned())
                .collect();
            assert!(!files.is_empty(), "{}: no PLC uploads", t.key);

            let mine: Vec<_> = all
                .iter()
                .filter(|f| {
                    f.file_path
                        .as_deref()
                        .is_some_and(|p| files.iter().any(|n| p.ends_with(n.as_str())))
                })
                .collect();
            let rules: BTreeSet<&str> = mine.iter().filter_map(|f| f.rule_id.as_deref()).collect();
            eprintln!("{} -> {} findings, rules {:?}", t.key, mine.len(), rules);

            if let Some(min) = t.expect.min_findings {
                assert!(
                    mine.len() >= min,
                    "{}: {} findings < min {min}",
                    t.key,
                    mine.len()
                );
            }
            for r in &t.expect.sast_rule_ids {
                assert!(
                    rules.contains(r.as_str()),
                    "{}: missing rule {r}; got {rules:?}",
                    t.key
                );
            }
        }
    }
}
