//! The unified onboarding model.
//!
//! An [`OnboardedTarget`] is the single source of truth for anything the scanner
//! can analyze. It records *what kind of software* the target is ([`TargetType`]),
//! the concrete [`Artifact`]s that were provided for it (a git repo, a firmware
//! image, a live URL, a PLC project, ...), the classifier's verdict, and the scan
//! configuration. It replaces the older git-only `TrackedRepository` and the
//! standalone `DastTarget`, both of which fold into this type as artifacts.

use std::collections::HashMap;

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use super::dast::{DastAuthConfig, DastTargetType};
use super::issue::TrackerType;
use super::pentest::{Environment, PentestConfig, PentestStrategy};
use super::scan::ScanType;

/// The family of software a target belongs to.
///
/// Targets look endlessly varied but fall into a small enumerable set classified
/// by where the analyzable signal lives. This drives the scan-applicability
/// matrix and the onboarding wizard's type selection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TargetType {
    /// Browser-facing web application (front end + server).
    WebApp,
    /// Headless backend service / API (REST, GraphQL, gRPC).
    BackendService,
    /// Desktop application (Windows/macOS/Linux GUI or CLI binary).
    DesktopApp,
    /// Android application (APK / AAB).
    AndroidApp,
    /// iOS application (IPA).
    IosApp,
    /// Bare-metal embedded firmware (no operating system).
    FirmwareBareMetal,
    /// Embedded firmware running on an RTOS (Zephyr, FreeRTOS, ...).
    FirmwareRtos,
    /// Embedded Linux built with Yocto / OpenEmbedded (BSP + image).
    EmbeddedLinuxYocto,
    /// Programmable logic controller software (IEC 61131-3, PLCopen / SPS).
    PlcSps,
}

impl std::fmt::Display for TargetType {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::WebApp => write!(f, "web_app"),
            Self::BackendService => write!(f, "backend_service"),
            Self::DesktopApp => write!(f, "desktop_app"),
            Self::AndroidApp => write!(f, "android_app"),
            Self::IosApp => write!(f, "ios_app"),
            Self::FirmwareBareMetal => write!(f, "firmware_bare_metal"),
            Self::FirmwareRtos => write!(f, "firmware_rtos"),
            Self::EmbeddedLinuxYocto => write!(f, "embedded_linux_yocto"),
            Self::PlcSps => write!(f, "plc_sps"),
        }
    }
}

/// The kind of artifact provided for a target.
///
/// Which scans are possible is a function of the target type *and* which of
/// these are present (SAST needs code, DAST needs a running URL, and so on).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ArtifactKind {
    /// A git repository (cloned for static analysis).
    GitRepo,
    /// A source archive (zip / tarball) with no live git remote.
    SourceArchive,
    /// A firmware image or binary blob.
    FirmwareImage,
    /// A mobile package: Android APK/AAB or iOS IPA.
    MobilePackage,
    /// An OCI/Docker container image reference.
    ContainerImage,
    /// A reachable running instance (base URL / endpoint) for dynamic testing.
    LiveUrl,
    /// A PLC project: PLCopen XML or Structured Text source.
    PlcProject,
    /// Free-form plaintext describing the target (feeds classification only).
    PlaintextDescription,
}

impl std::fmt::Display for ArtifactKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::GitRepo => write!(f, "git_repo"),
            Self::SourceArchive => write!(f, "source_archive"),
            Self::FirmwareImage => write!(f, "firmware_image"),
            Self::MobilePackage => write!(f, "mobile_package"),
            Self::ContainerImage => write!(f, "container_image"),
            Self::LiveUrl => write!(f, "live_url"),
            Self::PlcProject => write!(f, "plc_project"),
            Self::PlaintextDescription => write!(f, "plaintext_description"),
        }
    }
}

/// Credentials attached to an artifact.
///
/// This folds both `TrackedRepository`'s git auth (`auth_token` / `auth_username`
/// / SSH key) and `DastAuthConfig`'s HTTP auth (form / bearer / cookie) into one
/// shape so a single artifact carries whatever it needs to be fetched or probed.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ArtifactAuth {
    /// Auth method: `none` | `token` | `basic` | `bearer` | `cookie` | `form` | `ssh`.
    #[serde(default)]
    pub method: String,
    /// Username (git user, basic-auth user, or `x-access-token` for PATs).
    pub username: Option<String>,
    /// The secret credential: PAT, password, or bearer token. Encrypted at rest.
    pub secret: Option<String>,
    /// Path to an SSH private key for git-over-SSH.
    pub ssh_key_path: Option<String>,
    /// Login URL for form-based authentication.
    pub login_url: Option<String>,
    /// Extra headers to send when authenticating / probing.
    pub headers: Option<HashMap<String, String>>,
}

impl From<DastAuthConfig> for ArtifactAuth {
    fn from(c: DastAuthConfig) -> Self {
        Self {
            method: c.method,
            username: c.username,
            // Prefer a bearer token; otherwise fall back to the password.
            secret: c.token.or(c.password),
            ssh_key_path: None,
            login_url: c.login_url,
            headers: c.headers,
        }
    }
}

/// Git-specific configuration for a [`ArtifactKind::GitRepo`] artifact.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitArtifactConfig {
    /// Branch to scan.
    pub default_branch: String,
    /// Commit SHA of the last completed scan (change-detection watermark).
    pub last_scanned_commit: Option<String>,
    /// Local clone path once the repo has been fetched.
    pub local_path: Option<String>,
}

impl GitArtifactConfig {
    /// Config for a fresh git artifact on the given branch.
    pub fn on_branch(branch: impl Into<String>) -> Self {
        Self {
            default_branch: branch.into(),
            last_scanned_commit: None,
            local_path: None,
        }
    }
}

impl Default for GitArtifactConfig {
    fn default() -> Self {
        Self::on_branch("main")
    }
}

/// Dynamic-analysis configuration for a [`ArtifactKind::LiveUrl`] artifact.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebArtifactConfig {
    /// Whether the endpoint is a web app, REST API, or GraphQL API.
    pub target_kind: DastTargetType,
    /// URL paths to exclude from crawling / scanning.
    #[serde(default)]
    pub excluded_paths: Vec<String>,
    /// Maximum crawl depth.
    pub max_crawl_depth: u32,
    /// Rate limit in requests per second.
    pub rate_limit: u32,
    /// Whether destructive methods (DELETE / PUT) are permitted.
    #[serde(default)]
    pub allow_destructive: bool,
}

impl Default for WebArtifactConfig {
    fn default() -> Self {
        Self {
            target_kind: DastTargetType::WebApp,
            excluded_paths: Vec::new(),
            max_crawl_depth: 3,
            rate_limit: 10,
            allow_destructive: false,
        }
    }
}

/// The source format of a PLC project artifact.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlcFormat {
    /// PLCopen XML project export.
    PlcopenXml,
    /// IEC 61131-3 Structured Text source.
    StructuredText,
    /// A CODESYS project archive (`.projectarchive` — a zip bundling the project
    /// plus its referenced libraries and runtime; the source of the control-app SBOM).
    ProjectArchive,
}

/// PLC-specific configuration for a [`ArtifactKind::PlcProject`] artifact.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlcArtifactConfig {
    /// The project source format.
    pub format: PlcFormat,
}

/// A single fact discovered about a target by ingest or classification
/// (e.g. `language=rust`, `build_system=cmake`, `mcu=stm32f429`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DetectedFact {
    /// The fact name.
    pub key: String,
    /// The fact value.
    pub value: String,
    /// What produced the fact (e.g. `tramiton`, `language-fingerprint`).
    pub source: String,
}

impl DetectedFact {
    /// Build a fact from its parts.
    pub fn new(
        key: impl Into<String>,
        value: impl Into<String>,
        source: impl Into<String>,
    ) -> Self {
        Self {
            key: key.into(),
            value: value.into(),
            source: source.into(),
        }
    }
}

/// One concrete thing provided for a target: code, a binary, a URL, etc.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Artifact {
    /// Stable per-artifact id (UUID v4) — scan steps reference this.
    pub id: String,
    /// What kind of artifact this is.
    pub kind: ArtifactKind,
    /// The source reference: git URL, blob id, live URL, or image ref.
    pub source_ref: String,
    /// Optional human-friendly label.
    pub display_name: Option<String>,
    /// Content-addressed storage path once ingested (blobs only).
    pub stored_path: Option<String>,
    /// SHA-256 of the ingested content (git artifacts store the head SHA).
    pub content_hash: Option<String>,
    /// Size of the stored blob in bytes.
    pub size_bytes: Option<u64>,
    /// Credentials for fetching or probing this artifact.
    pub auth: Option<ArtifactAuth>,
    /// Git configuration (present for [`ArtifactKind::GitRepo`]).
    pub git: Option<GitArtifactConfig>,
    /// Dynamic-analysis configuration (present for [`ArtifactKind::LiveUrl`]).
    pub web: Option<WebArtifactConfig>,
    /// PLC configuration (present for [`ArtifactKind::PlcProject`]).
    pub plc: Option<PlcArtifactConfig>,
    /// Facts discovered about this artifact by ingest / classification.
    #[serde(default)]
    pub detected: Vec<DetectedFact>,
    /// When this artifact was last ingested.
    #[serde(default, with = "super::serde_helpers::opt_bson_datetime")]
    pub ingested_at: Option<DateTime<Utc>>,
}

impl Artifact {
    /// A bare artifact of the given kind and source reference.
    fn bare(kind: ArtifactKind, source_ref: impl Into<String>) -> Self {
        Self {
            id: uuid::Uuid::new_v4().to_string(),
            kind,
            source_ref: source_ref.into(),
            display_name: None,
            stored_path: None,
            content_hash: None,
            size_bytes: None,
            auth: None,
            git: None,
            web: None,
            plc: None,
            detected: Vec::new(),
            ingested_at: None,
        }
    }

    /// A git-repository artifact tracking the given branch.
    pub fn git_repo(url: impl Into<String>, branch: impl Into<String>) -> Self {
        let mut a = Self::bare(ArtifactKind::GitRepo, url);
        a.git = Some(GitArtifactConfig::on_branch(branch));
        a
    }

    /// A live-URL artifact with default crawl settings.
    pub fn live_url(url: impl Into<String>) -> Self {
        let mut a = Self::bare(ArtifactKind::LiveUrl, url);
        a.web = Some(WebArtifactConfig::default());
        a
    }

    /// A firmware-image artifact referenced by name (blob ingested later).
    pub fn firmware_image(source_ref: impl Into<String>) -> Self {
        Self::bare(ArtifactKind::FirmwareImage, source_ref)
    }

    /// A source-archive artifact referenced by name (blob ingested later).
    pub fn source_archive(source_ref: impl Into<String>) -> Self {
        Self::bare(ArtifactKind::SourceArchive, source_ref)
    }

    /// A mobile-package artifact (APK/AAB/IPA) referenced by name.
    pub fn mobile_package(source_ref: impl Into<String>) -> Self {
        Self::bare(ArtifactKind::MobilePackage, source_ref)
    }

    /// A container-image artifact referenced by OCI ref.
    pub fn container_image(source_ref: impl Into<String>) -> Self {
        Self::bare(ArtifactKind::ContainerImage, source_ref)
    }

    /// A PLC-project artifact in the given format.
    pub fn plc_project(source_ref: impl Into<String>, format: PlcFormat) -> Self {
        let mut a = Self::bare(ArtifactKind::PlcProject, source_ref);
        a.plc = Some(PlcArtifactConfig { format });
        a
    }

    /// A plaintext-description artifact (classification input only).
    pub fn plaintext(text: impl Into<String>) -> Self {
        Self::bare(ArtifactKind::PlaintextDescription, text)
    }
}

/// One ranked candidate produced by the classifier.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TargetTypeCandidate {
    /// The candidate target type.
    pub target_type: TargetType,
    /// Confidence in `[0.0, 1.0]`.
    pub confidence: f32,
    /// Why this candidate was proposed.
    pub rationale: String,
}

/// The classifier's verdict for a target: a suggested type plus ranked
/// alternatives and the facts the decision rested on.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Classification {
    /// The top-ranked target type.
    pub suggested: TargetType,
    /// All candidates, sorted by descending confidence.
    #[serde(default)]
    pub candidates: Vec<TargetTypeCandidate>,
    /// Facts gathered during classification.
    #[serde(default)]
    pub facts: Vec<DetectedFact>,
    /// Which classifiers contributed (e.g. `["tramiton", "language-fingerprint"]`).
    #[serde(default)]
    pub detected_by: Vec<String>,
    /// When classification ran.
    #[serde(with = "super::serde_helpers::bson_datetime")]
    pub detected_at: DateTime<Utc>,
    /// Whether a human confirmed the suggestion.
    #[serde(default)]
    pub confirmed: bool,
}

/// Issue-tracker linkage, migrated from `TrackedRepository`'s `tracker_*` fields.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IssueTrackerConfig {
    /// The tracker platform.
    pub tracker_type: Option<TrackerType>,
    /// Tracker owner / organization.
    pub owner: Option<String>,
    /// Tracker repository / project.
    pub repo: Option<String>,
    /// Per-target tracker access token.
    pub token: Option<String>,
}

/// How a target should be scanned.
///
/// `enabled_scans` / `disabled_scans` override the scan-applicability matrix
/// defaults; the pentest and tracker blocks reuse the existing wizard config.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TargetScanConfig {
    /// Scans explicitly turned on (empty means "use matrix defaults").
    #[serde(default)]
    pub enabled_scans: Vec<ScanType>,
    /// Scans explicitly turned off.
    #[serde(default)]
    pub disabled_scans: Vec<ScanType>,
    /// Target environment (gates destructive / active testing).
    #[serde(default)]
    pub environment: Environment,
    /// Whether destructive tests are permitted for this target.
    #[serde(default)]
    pub allow_destructive: bool,
    /// Pentest strategy selector.
    pub strategy: Option<PentestStrategy>,
    /// Full pentest wizard configuration.
    pub pentest: Option<PentestConfig>,
    /// Issue-tracker linkage.
    pub issue_tracker: Option<IssueTrackerConfig>,
}

/// A sibling product in the company suite that may already hold authoritative
/// data for a target. compliance-scanner reconciles with these rather than
/// recomputing what they already know.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExternalSystem {
    /// Reproducible-build & firmware compliance engine (build plan, SBOM, VEX,
    /// attestation).
    Tramiton,
    /// Code assistant (downstream remediation consumer).
    Werkpilot,
    /// Compliance-controls RAG (atomic controls derived from laws).
    BreakpilotCompliance,
}

impl std::fmt::Display for ExternalSystem {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Tramiton => write!(f, "tramiton"),
            Self::Werkpilot => write!(f, "werkpilot"),
            Self::BreakpilotCompliance => write!(f, "breakpilot_compliance"),
        }
    }
}

/// A link from this target to a record in a sibling product, used to reconcile
/// existing evidence instead of recomputing it.
///
/// For tramiton, `project_id` is the shared cross-product key and
/// `subject_sha256` matches a firmware artifact's [`Artifact::content_hash`]
/// (which equals tramiton's `Artifact.sha256`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExternalRef {
    /// Which sibling product this reference points at.
    pub system: ExternalSystem,
    /// The sibling product's project identifier, if known.
    pub project_id: Option<String>,
    /// Content digest of the subject artifact (firmware sha256), if known.
    pub subject_sha256: Option<String>,
    /// Reconciliation status: `linked` | `reconciled` | `unavailable`.
    #[serde(default)]
    pub status: String,
    /// Opaque, offline-verifiable entitlement grant (e.g. tramiton's signed
    /// `LicenseGrant`), if the tenant provided one.
    pub license_grant: Option<String>,
    /// When evidence was last reconciled from this system.
    #[serde(default, with = "super::serde_helpers::opt_bson_datetime")]
    pub last_reconciled_at: Option<DateTime<Utc>>,
}

impl ExternalRef {
    /// A freshly linked (not yet reconciled) reference to a sibling system.
    pub fn linked(system: ExternalSystem) -> Self {
        Self {
            system,
            project_id: None,
            subject_sha256: None,
            status: "linked".to_string(),
            license_grant: None,
            last_reconciled_at: None,
        }
    }
}

/// A regulatory / standards framework a target must comply with. Drives which
/// controls the mapping engine pulls from the [`crate::traits::ControlsProvider`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComplianceFramework {
    /// EU Cyber Resilience Act.
    Cra,
    /// IEC 62443 (industrial automation & control systems security).
    Iec62443,
    /// EU General Data Protection Regulation.
    Gdpr,
    /// SOC 2.
    Soc2,
    /// ISO/IEC 27001.
    Iso27001,
    /// EU Radio Equipment Directive (RED) cybersecurity articles.
    RedDirective,
}

impl std::fmt::Display for ComplianceFramework {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Cra => write!(f, "cra"),
            Self::Iec62443 => write!(f, "iec_62443"),
            Self::Gdpr => write!(f, "gdpr"),
            Self::Soc2 => write!(f, "soc2"),
            Self::Iso27001 => write!(f, "iso_27001"),
            Self::RedDirective => write!(f, "red_directive"),
        }
    }
}

/// The compliance scope of a target: which frameworks apply and, optionally, the
/// jurisdiction. Captured at onboarding (with per-target-type defaults from
/// [`default_compliance_profile`]).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ComplianceProfile {
    /// Applicable frameworks.
    #[serde(default)]
    pub frameworks: Vec<ComplianceFramework>,
    /// Free-form jurisdiction (e.g. `eu`, `us`, `de`).
    pub jurisdiction: Option<String>,
}

/// The sensible default compliance scope for a target type. Firmware / PLC /
/// embedded default to CRA + IEC 62443; software defaults to GDPR + SOC 2.
pub fn default_compliance_profile(target_type: TargetType) -> ComplianceProfile {
    use ComplianceFramework::{Cra, Gdpr, Iec62443, Soc2};
    let frameworks = match target_type {
        TargetType::PlcSps
        | TargetType::FirmwareBareMetal
        | TargetType::FirmwareRtos
        | TargetType::EmbeddedLinuxYocto => vec![Cra, Iec62443],
        TargetType::WebApp | TargetType::BackendService => vec![Gdpr, Soc2],
        TargetType::DesktopApp | TargetType::AndroidApp | TargetType::IosApp => {
            vec![Gdpr, Cra]
        }
    };
    ComplianceProfile {
        frameworks,
        jurisdiction: None,
    }
}

/// A target onboarded for scanning: the unified replacement for the legacy
/// `TrackedRepository` (SAST) and `DastTarget` (DAST) records.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OnboardedTarget {
    /// Mongo id. Preserved from the legacy record during migration so every
    /// downstream collection keyed by `repo_id` / `target_id` keeps resolving.
    #[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
    pub id: Option<bson::oid::ObjectId>,
    /// Human-friendly name.
    #[serde(default)]
    pub name: String,
    /// The software family this target belongs to.
    pub target_type: TargetType,
    /// Optional free-form description (also a classification input).
    pub description: Option<String>,
    /// The artifacts provided for this target.
    #[serde(default)]
    pub artifacts: Vec<Artifact>,
    /// The classifier's verdict, once run.
    pub classification: Option<Classification>,
    /// How this target should be scanned.
    #[serde(default)]
    pub scan_config: TargetScanConfig,
    /// The compliance scope (applicable frameworks / jurisdiction).
    #[serde(default)]
    pub compliance_profile: ComplianceProfile,
    /// Links to sibling products (tramiton, ...) holding reconcilable evidence.
    #[serde(default)]
    pub external_refs: Vec<ExternalRef>,
    /// Cron schedule for recurring scans, if any.
    pub scan_schedule: Option<String>,
    /// Whether inbound webhooks are enabled for this target.
    #[serde(default)]
    pub webhook_enabled: bool,
    /// HMAC secret for verifying inbound webhooks.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub webhook_secret: Option<String>,
    /// Cached count of findings across this target's scans.
    #[serde(default)]
    pub findings_count: u32,
    /// Creation timestamp.
    #[serde(
        default = "chrono::Utc::now",
        with = "super::serde_helpers::bson_datetime"
    )]
    pub created_at: DateTime<Utc>,
    /// Last-update timestamp.
    #[serde(
        default = "chrono::Utc::now",
        with = "super::serde_helpers::bson_datetime"
    )]
    pub updated_at: DateTime<Utc>,
}

impl OnboardedTarget {
    /// A new target of the given type with a freshly generated webhook secret.
    pub fn new(name: String, target_type: TargetType) -> Self {
        let now = Utc::now();
        let webhook_secret = uuid::Uuid::new_v4().to_string().replace('-', "");
        Self {
            id: None,
            name,
            target_type,
            description: None,
            artifacts: Vec::new(),
            classification: None,
            scan_config: TargetScanConfig::default(),
            compliance_profile: default_compliance_profile(target_type),
            external_refs: Vec::new(),
            scan_schedule: None,
            webhook_enabled: false,
            webhook_secret: Some(webhook_secret),
            findings_count: 0,
            created_at: now,
            updated_at: now,
        }
    }

    /// The first artifact of the given kind, if present.
    pub fn first_of(&self, kind: ArtifactKind) -> Option<&Artifact> {
        self.artifacts.iter().find(|a| a.kind == kind)
    }

    /// Whether the target has at least one artifact of the given kind.
    pub fn has(&self, kind: ArtifactKind) -> bool {
        self.artifacts.iter().any(|a| a.kind == kind)
    }

    /// The primary code artifact (git repo or source archive), if any.
    pub fn code_artifact(&self) -> Option<&Artifact> {
        self.artifacts
            .iter()
            .find(|a| matches!(a.kind, ArtifactKind::GitRepo | ArtifactKind::SourceArchive))
    }

    /// The live-URL artifact, if any.
    pub fn live_url(&self) -> Option<&Artifact> {
        self.first_of(ArtifactKind::LiveUrl)
    }
}

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

    fn sample_target() -> OnboardedTarget {
        let mut t = OnboardedTarget::new("acme-web".to_string(), TargetType::WebApp);
        t.artifacts.push(Artifact::git_repo(
            "https://git.example.com/acme.git",
            "main",
        ));
        t.artifacts
            .push(Artifact::live_url("https://acme.example.com"));
        t
    }

    #[test]
    fn onboarded_target_bson_round_trip() {
        let t = sample_target();
        let b = bson::to_bson(&t).expect("serialize");
        let back: OnboardedTarget = bson::from_bson(b.clone()).expect("deserialize");
        let b2 = bson::to_bson(&back).expect("re-serialize");
        assert_eq!(b, b2);
    }

    #[test]
    fn enum_display_is_snake_case() {
        assert_eq!(
            TargetType::FirmwareBareMetal.to_string(),
            "firmware_bare_metal"
        );
        assert_eq!(TargetType::PlcSps.to_string(), "plc_sps");
        assert_eq!(ArtifactKind::PlcProject.to_string(), "plc_project");
        assert_eq!(ArtifactKind::MobilePackage.to_string(), "mobile_package");
    }

    #[test]
    fn helpers_locate_artifacts() {
        let t = sample_target();
        assert!(t.has(ArtifactKind::GitRepo));
        assert!(t.live_url().is_some());
        assert!(t.code_artifact().is_some());
        assert!(!t.has(ArtifactKind::FirmwareImage));
        assert_eq!(
            t.first_of(ArtifactKind::GitRepo).map(|a| a.kind),
            Some(ArtifactKind::GitRepo)
        );
    }

    #[test]
    fn new_target_generates_webhook_secret() {
        let t = OnboardedTarget::new("t".to_string(), TargetType::BackendService);
        let secret = t.webhook_secret.expect("secret present");
        assert_eq!(secret.len(), 32);
        assert!(!secret.contains('-'));
    }

    #[test]
    fn dast_auth_folds_into_artifact_auth() {
        let dast = DastAuthConfig {
            method: "bearer".to_string(),
            login_url: Some("https://x/login".to_string()),
            username: Some("user".to_string()),
            password: Some("pw".to_string()),
            token: Some("tok".to_string()),
            headers: None,
        };
        let auth = ArtifactAuth::from(dast);
        assert_eq!(auth.method, "bearer");
        // Bearer token wins over password.
        assert_eq!(auth.secret.as_deref(), Some("tok"));
        assert_eq!(auth.login_url.as_deref(), Some("https://x/login"));
    }

    #[test]
    fn each_artifact_gets_a_unique_id() {
        let a = Artifact::firmware_image("fw.bin");
        let b = Artifact::firmware_image("fw.bin");
        assert_ne!(a.id, b.id);
    }

    #[test]
    fn firmware_default_profile_is_cra_and_62443() {
        let p = default_compliance_profile(TargetType::FirmwareBareMetal);
        assert!(p.frameworks.contains(&ComplianceFramework::Cra));
        assert!(p.frameworks.contains(&ComplianceFramework::Iec62443));
    }

    #[test]
    fn webapp_default_profile_is_gdpr_and_soc2() {
        let p = default_compliance_profile(TargetType::WebApp);
        assert!(p.frameworks.contains(&ComplianceFramework::Gdpr));
        assert!(p.frameworks.contains(&ComplianceFramework::Soc2));
    }

    #[test]
    fn new_target_gets_default_profile_and_no_external_refs() {
        let t = OnboardedTarget::new("fw".to_string(), TargetType::FirmwareRtos);
        assert!(!t.compliance_profile.frameworks.is_empty());
        assert!(t.external_refs.is_empty());
    }

    #[test]
    fn external_ref_linked_defaults() {
        let r = ExternalRef::linked(ExternalSystem::Tramiton);
        assert_eq!(r.system, ExternalSystem::Tramiton);
        assert_eq!(r.status, "linked");
        assert!(r.project_id.is_none());
        assert!(r.last_reconciled_at.is_none());
    }
}
