//! The scan plan.
//!
//! [`build_scan_plan`] turns an [`OnboardedTarget`] into the concrete ordered
//! list of scans to run, each bound to the artifact it consumes. It intersects
//! the scan-applicability matrix ([`applicable_scans`]) with the target's
//! `scan_config` overrides: a scan runs when its required artifact is present and
//! it is either on by default or explicitly enabled, and is not explicitly
//! disabled. This is the decision engine the unified pipeline (`run_target`)
//! executes.

use compliance_core::models::{Artifact, ArtifactKind, OnboardedTarget, ScanPhase, ScanType};
use compliance_core::scan_matrix::applicable_scans;

/// One scan to run, bound to the artifact it operates on.
#[derive(Debug, Clone, PartialEq)]
pub struct ScanStep {
    /// The scan to run.
    pub scan_type: ScanType,
    /// The pipeline phase to report while it runs.
    pub phase: ScanPhase,
    /// The id of the artifact this scan consumes ([`Artifact::id`]).
    pub artifact_id: String,
}

/// The ordered set of scans to run for a target.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ScanPlan {
    /// The scans, in matrix order.
    pub steps: Vec<ScanStep>,
}

impl ScanPlan {
    /// Whether the plan contains a step for the given scan type.
    pub fn has(&self, scan: ScanType) -> bool {
        self.steps.iter().any(|s| s.scan_type == scan)
    }

    /// Whether the plan is empty (nothing to run).
    pub fn is_empty(&self) -> bool {
        self.steps.is_empty()
    }
}

/// Build the scan plan for a target: matrix defaults ∩ `scan_config`, each scan
/// bound to the artifact it consumes. Scans whose required artifact is absent, or
/// that are disabled, or off-by-default and not explicitly enabled, are dropped.
pub fn build_scan_plan(target: &OnboardedTarget) -> ScanPlan {
    let enabled = &target.scan_config.enabled_scans;
    let disabled = &target.scan_config.disabled_scans;

    let mut steps = Vec::new();
    for option in applicable_scans(target) {
        // Required artifact missing → not runnable.
        if option.blocked_reason.is_some() {
            continue;
        }
        // Explicit opt-out wins.
        if disabled.contains(&option.scan) {
            continue;
        }
        // Run if on by default, or explicitly enabled.
        if !option.default_on && !enabled.contains(&option.scan) {
            continue;
        }
        let Some(artifact) = resolve_artifact(target, option.required_artifact) else {
            continue;
        };
        steps.push(ScanStep {
            scan_type: option.scan,
            phase: phase_for(option.scan),
            artifact_id: artifact.id.clone(),
        });
    }
    ScanPlan { steps }
}

/// Resolve the artifact a scan consumes. A "code" requirement (represented by
/// `GitRepo`) is satisfied by a git repo *or* a source archive. The PLC
/// control-logic requirement (represented by `PlcProject`) prefers an uploaded
/// PLC project but also accepts a code artifact — a git repo / source archive
/// holding PLCopen XML / ST exports.
fn resolve_artifact(target: &OnboardedTarget, required: Option<ArtifactKind>) -> Option<&Artifact> {
    match required {
        Some(ArtifactKind::GitRepo) => target.code_artifact(),
        Some(ArtifactKind::PlcProject) => target
            .first_of(ArtifactKind::PlcProject)
            .or_else(|| target.code_artifact()),
        Some(kind) => target.first_of(kind),
        None => target.code_artifact().or_else(|| target.artifacts.first()),
    }
}

/// The pipeline phase reported while a given scan runs.
fn phase_for(scan: ScanType) -> ScanPhase {
    match scan {
        ScanType::Sast => ScanPhase::Sast,
        ScanType::Sbom => ScanPhase::SbomGeneration,
        ScanType::Cve => ScanPhase::CveScanning,
        ScanType::Gdpr | ScanType::OAuth => ScanPhase::PatternScanning,
        ScanType::SecretDetection => ScanPhase::SecretDetection,
        ScanType::Lint => ScanPhase::LintScanning,
        ScanType::CodeReview => ScanPhase::CodeReview,
        ScanType::Graph => ScanPhase::GraphBuilding,
        ScanType::Dast => ScanPhase::DastScanning,
        ScanType::FirmwareStatic => ScanPhase::FirmwareStatic,
        ScanType::PlcControlLogic => ScanPhase::PlcAnalysis,
        ScanType::MobileStatic => ScanPhase::MobileStatic,
        ScanType::ContainerScan => ScanPhase::ContainerScan,
        ScanType::IcsProbe => ScanPhase::IcsProbe,
    }
}

#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
    use super::*;
    use compliance_core::models::{PlcFormat, TargetType};

    fn target(target_type: TargetType, artifacts: Vec<Artifact>) -> OnboardedTarget {
        let mut t = OnboardedTarget::new("t".to_string(), target_type);
        t.artifacts = artifacts;
        t
    }

    fn step_for<'a>(plan: &'a ScanPlan, scan: ScanType) -> Option<&'a ScanStep> {
        plan.steps.iter().find(|s| s.scan_type == scan)
    }

    #[test]
    fn webapp_with_code_and_url_runs_sast_and_dast_bound_to_the_right_artifacts() {
        let code = Artifact::git_repo("https://git/x", "main");
        let url = Artifact::live_url("https://x");
        let (code_id, url_id) = (code.id.clone(), url.id.clone());
        let t = target(TargetType::WebApp, vec![code, url]);

        let plan = build_scan_plan(&t);
        let sast = step_for(&plan, ScanType::Sast).expect("sast planned");
        assert_eq!(sast.artifact_id, code_id);
        assert_eq!(sast.phase, ScanPhase::Sast);
        let dast = step_for(&plan, ScanType::Dast).expect("dast planned");
        assert_eq!(dast.artifact_id, url_id);
    }

    #[test]
    fn webapp_without_url_omits_dast() {
        let t = target(TargetType::WebApp, vec![Artifact::git_repo("u", "main")]);
        let plan = build_scan_plan(&t);
        assert!(plan.has(ScanType::Sast));
        assert!(!plan.has(ScanType::Dast));
    }

    #[test]
    fn code_scan_binds_to_source_archive_when_no_git_repo() {
        let arc = Artifact::source_archive("src.zip");
        let arc_id = arc.id.clone();
        let t = target(TargetType::BackendService, vec![arc]);
        let plan = build_scan_plan(&t);
        let sast = step_for(&plan, ScanType::Sast).expect("sast planned");
        assert_eq!(sast.artifact_id, arc_id);
    }

    #[test]
    fn firmware_sbom_and_cve_bind_to_the_firmware_image() {
        let fw = Artifact::firmware_image("fw.bin");
        let fw_id = fw.id.clone();
        let t = target(TargetType::FirmwareBareMetal, vec![fw]);
        let plan = build_scan_plan(&t);
        let sbom = step_for(&plan, ScanType::Sbom).expect("sbom planned");
        assert_eq!(sbom.artifact_id, fw_id);
        assert!(step_for(&plan, ScanType::FirmwareStatic).is_some());
        assert!(!plan.has(ScanType::Dast));
    }

    #[test]
    fn plc_plans_only_control_logic() {
        let plc = Artifact::plc_project("p.xml", PlcFormat::PlcopenXml);
        let t = target(TargetType::PlcSps, vec![plc]);
        let plan = build_scan_plan(&t);
        assert_eq!(plan.steps.len(), 1);
        assert_eq!(plan.steps[0].scan_type, ScanType::PlcControlLogic);
        assert_eq!(plan.steps[0].phase, ScanPhase::PlcAnalysis);
    }

    #[test]
    fn plc_control_logic_binds_to_a_git_repo() {
        // A CODESYS project in git (PLCopen XML / ST exports) with no uploaded
        // PlcProject: control-logic still plans, bound to the git artifact.
        let git = Artifact::git_repo("https://git/plc", "main");
        let git_id = git.id.clone();
        let t = target(TargetType::PlcSps, vec![git]);
        let plan = build_scan_plan(&t);
        let step = step_for(&plan, ScanType::PlcControlLogic).expect("control-logic planned");
        assert_eq!(step.artifact_id, git_id, "PLC scan binds to the git repo");
    }

    #[test]
    fn disabled_scan_is_dropped_and_off_by_default_can_be_enabled() {
        let mut t = target(TargetType::WebApp, vec![Artifact::git_repo("u", "main")]);
        t.scan_config.disabled_scans = vec![ScanType::Lint];
        // CodeReview is off by default for web; enable it explicitly.
        t.scan_config.enabled_scans = vec![ScanType::CodeReview];
        let plan = build_scan_plan(&t);
        assert!(!plan.has(ScanType::Lint));
        assert!(plan.has(ScanType::CodeReview));
        assert!(plan.has(ScanType::Sast));
    }

    #[test]
    fn no_code_artifact_yields_empty_plan_for_web() {
        let t = target(TargetType::WebApp, vec![]);
        let plan = build_scan_plan(&t);
        assert!(plan.is_empty());
    }
}
