//! The Werkbank job/result contract (WB-01).
//!
//! The shared, dependency-free vocabulary the control plane and the Werkbank
//! execution runner agree on: what a [`Job`] is, which [`Executor`] runs it, how
//! it moves through the queue ([`JobStatus`]), and what a [`JobResult`] carries
//! back. Jobs are declarative — TOML on disk, JSON on the wire — and results
//! reuse the existing scanner result types ([`Finding`], [`DastFinding`],
//! [`SbomEntry`]) so the runner produces exactly what the control plane persists.
//!
//! This module is intentionally free of the `mongodb`/`axum` features so the
//! runner can depend on `compliance-core` without pulling the server stack.

use std::collections::BTreeMap;

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

use super::dast::DastFinding;
use super::finding::Finding;
use super::sbom::SbomEntry;

/// The kind of dynamic-execution job.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum JobType {
    /// Instantiate control logic on an ephemeral soft-PLC and probe it.
    PlcProvision,
    /// Boot a firmware image under QEMU and run dynamic checks.
    QemuBoot,
    /// Crawl and dynamically test a running web endpoint.
    Dast,
    /// Run an active penetration test against a running target.
    Pentest,
}

/// How a runner executes a job — the CI-runner-style classification. A runner
/// advertises exactly one; a job requires one.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Executor {
    /// A subprocess on the runner host (dev / trusted single-node).
    Shell,
    /// One or more containers on the runner's Docker (default; QEMU runs here).
    Docker,
    /// A Pod/Job in a Kubernetes cluster (scale-out / multi-tenant).
    K8s,
}

/// Lifecycle state of a job in the queue.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum JobStatus {
    /// Waiting to be leased.
    Queued,
    /// Leased by a runner but not yet started.
    Leased,
    /// Executing on a runner.
    Running,
    /// Completed successfully.
    Succeeded,
    /// Completed with an error.
    Failed,
    /// The lease/lifetime deadline elapsed before completion.
    Expired,
    /// Cancelled by the control plane.
    Cancelled,
}

impl JobStatus {
    /// Whether the job has reached a terminal state (no further transitions).
    pub fn is_terminal(self) -> bool {
        matches!(
            self,
            JobStatus::Succeeded | JobStatus::Failed | JobStatus::Expired | JobStatus::Cancelled
        )
    }
}

/// A reference to an input artifact. Resolved by the runner from a source it can
/// reach; the blob itself never flows through the control plane (so an on-prem
/// runner keeps customer data local). Exactly one of `blob`/`url` should be set.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct InputRef {
    /// Content-addressed blob (e.g. `sha256:…`) the runner fetches from its store.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub blob: Option<String>,
    /// A URL the runner can reach (git repo, internal artifact store, …).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
}

impl InputRef {
    /// A content-addressed blob reference.
    pub fn blob(id: impl Into<String>) -> Self {
        Self {
            blob: Some(id.into()),
            url: None,
        }
    }
}

/// Sandbox runtime knobs. Fields are executor/job-type specific and all optional;
/// `extra` carries anything not modelled explicitly.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct JobRuntime {
    /// Container image (Docker executor).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub image: Option<String>,
    /// Memory cap (e.g. `512m`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub memory: Option<String>,
    /// CPU cap (e.g. `0.5`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cpus: Option<String>,
    /// Network to join (e.g. `isolated`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub network: Option<String>,
    /// QEMU machine type (qemu-boot).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub machine: Option<String>,
    /// QEMU target architecture (qemu-boot).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub arch: Option<String>,
    /// Executor-specific extras not modelled above.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub extra: BTreeMap<String, String>,
}

/// DAST collection settings for jobs that scan a web endpoint.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DastCollect {
    /// Maximum crawl depth (kept shallow for ephemeral instances).
    pub max_crawl_depth: u32,
}

/// What to collect from a run.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct JobCollect {
    /// Run the industrial-protocol probe (Modbus/OPC-UA/EtherNet-IP).
    #[serde(default)]
    pub ics_probe: bool,
    /// Run DAST against the provisioned/booted web endpoint.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dast: Option<DastCollect>,
    /// Run an active pentest.
    #[serde(default)]
    pub pentest: bool,
    /// Collect an SBOM.
    #[serde(default)]
    pub sbom: bool,
}

/// A declarative dynamic-execution job the control plane enqueues and a Werkbank
/// runner leases and executes.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Job {
    /// Unique job id (assigned by the control plane on enqueue).
    pub id: String,
    /// What kind of job this is.
    #[serde(rename = "type")]
    pub job_type: JobType,
    /// Owning tenant.
    pub tenant: String,
    /// The onboarded target this job tests.
    pub target_id: String,
    /// The executor a runner must provide to run this job.
    pub executor: Executor,
    /// Runner capabilities this job requires (e.g. `arch=amd64`, `kvm=true`).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub labels: Vec<String>,
    /// Hard lifetime deadline for the whole job.
    pub timeout_secs: u64,
    /// Named input artifacts (e.g. `program`, `firmware`), by reference.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub inputs: BTreeMap<String, InputRef>,
    /// Sandbox runtime knobs.
    #[serde(default)]
    pub runtime: JobRuntime,
    /// What to collect from the run.
    #[serde(default)]
    pub collect: JobCollect,
}

impl Job {
    /// A `plc-provision` job: instantiate the control logic named `program` on an
    /// ephemeral soft-PLC (Docker executor) and collect the ICS probe + DAST.
    pub fn plc_provision(
        id: impl Into<String>,
        tenant: impl Into<String>,
        target_id: impl Into<String>,
        program: InputRef,
        timeout_secs: u64,
    ) -> Self {
        let mut inputs = BTreeMap::new();
        inputs.insert("program".to_string(), program);
        Self {
            id: id.into(),
            job_type: JobType::PlcProvision,
            tenant: tenant.into(),
            target_id: target_id.into(),
            executor: Executor::Docker,
            labels: Vec::new(),
            timeout_secs,
            inputs,
            runtime: JobRuntime::default(),
            collect: JobCollect {
                ics_probe: true,
                dast: Some(DastCollect { max_crawl_depth: 2 }),
                pentest: false,
                sbom: false,
            },
        }
    }
}

/// The outcome of running a job, posted back to the control plane. Findings and
/// SBOM reuse the shared scanner types, so the control plane persists them
/// unchanged. Submission is idempotent — keyed by [`JobResult::job_id`].
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct JobResult {
    /// The job this result is for.
    pub job_id: String,
    /// Terminal status of the job.
    pub status: Option<JobStatus>,
    /// General scanner findings (e.g. ICS-probe findings).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub findings: Vec<Finding>,
    /// DAST findings from a web-endpoint scan.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub dast_findings: Vec<DastFinding>,
    /// SBOM components collected from the run.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub sbom: Vec<SbomEntry>,
    /// Error message when the job failed.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
    /// Captured execution log (truncated by the runner).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub logs: Option<String>,
    /// When execution started on the runner.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub started_at: Option<DateTime<Utc>>,
    /// When execution finished.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub finished_at: Option<DateTime<Utc>>,
}

impl JobResult {
    /// A successful result for a job.
    pub fn succeeded(job_id: impl Into<String>) -> Self {
        Self {
            job_id: job_id.into(),
            status: Some(JobStatus::Succeeded),
            ..Default::default()
        }
    }

    /// A failed result carrying an error message.
    pub fn failed(job_id: impl Into<String>, error: impl Into<String>) -> Self {
        Self {
            job_id: job_id.into(),
            status: Some(JobStatus::Failed),
            error: Some(error.into()),
            ..Default::default()
        }
    }
}

/// A queued job as persisted by the control plane (WB-02): the [`Job`] contract
/// plus the queue bookkeeping — status, lease ownership, attempt count, and the
/// eventual result. The runner never sees this record; on lease it receives a
/// [`LeasedJob`] (the job plus a token it presents to heartbeat/complete).
///
/// Timestamps persist as native BSON dates so the queue's range queries (lease
/// FIFO by `created_at`, visibility-timeout sweep by `lease_expires_at`) compare
/// correctly.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobRecord {
    /// The job to run.
    pub job: Job,
    /// Current queue state.
    pub status: JobStatus,
    /// The lease token held by the current runner (proves lease ownership).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub lease_token: Option<String>,
    /// Id of the runner holding the lease.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub leased_by: Option<String>,
    /// When the current lease expires — the visibility timeout after which a
    /// crashed runner's job is swept back to `queued`.
    #[serde(default, with = "super::serde_helpers::opt_bson_datetime")]
    pub lease_expires_at: Option<DateTime<Utc>>,
    /// Last heartbeat from the runner.
    #[serde(default, with = "super::serde_helpers::opt_bson_datetime")]
    pub heartbeat_at: Option<DateTime<Utc>>,
    /// How many times the job has been leased (incremented on each lease).
    #[serde(default)]
    pub attempts: u32,
    /// Set when the control plane requests cancellation; the runner sees it on
    /// its next heartbeat and aborts.
    #[serde(default)]
    pub cancel_requested: bool,
    /// The result, once the job reaches a terminal state.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub result: Option<JobResult>,
    /// When the job was enqueued.
    #[serde(with = "super::serde_helpers::bson_datetime")]
    pub created_at: DateTime<Utc>,
    /// Last modification.
    #[serde(with = "super::serde_helpers::bson_datetime")]
    pub updated_at: DateTime<Utc>,
}

impl JobRecord {
    /// A freshly-enqueued (`queued`) record for a job.
    pub fn queued(job: Job, now: DateTime<Utc>) -> Self {
        Self {
            job,
            status: JobStatus::Queued,
            lease_token: None,
            leased_by: None,
            lease_expires_at: None,
            heartbeat_at: None,
            attempts: 0,
            cancel_requested: false,
            result: None,
            created_at: now,
            updated_at: now,
        }
    }
}

/// A job handed to a runner on lease: what to run plus the token the runner must
/// present to heartbeat and complete it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LeasedJob {
    /// The job to execute.
    pub job: Job,
    /// The lease token proving ownership (opaque to the runner).
    pub lease_token: String,
}

/// The runner's view of a heartbeat: whether the control plane has asked the job
/// to stop. `None` from the queue means the lease was lost (token mismatch or the
/// job already terminal) and the runner should abandon the work.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct HeartbeatAck {
    /// The control plane requested cancellation — the runner should tear down.
    pub cancelled: bool,
}

// --- Runner ↔ control-plane transport (the pull API wire types) ---------------
// Shared so the runner (client) and the control plane (server) agree on shapes.

/// Runner → control plane: lease the oldest runnable job for this runner.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LeaseRequest {
    /// The tenant queue to lease from.
    pub tenant: String,
    /// The runner id (advertised for attribution).
    pub runner_id: String,
    /// The executor this runner provides.
    pub executor: Executor,
    /// The capability labels this runner advertises.
    #[serde(default)]
    pub labels: Vec<String>,
    /// Requested lease lifetime (the visibility timeout), in seconds.
    pub lease_ttl_secs: u64,
}

/// Runner → control plane: prove lease ownership and extend it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeartbeatRequest {
    /// The tenant queue.
    pub tenant: String,
    /// The job being worked.
    pub job_id: String,
    /// The lease token from the [`LeasedJob`].
    pub lease_token: String,
    /// Lease lifetime to extend to, in seconds.
    pub lease_ttl_secs: u64,
}

/// Runner → control plane: record a job's terminal result.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompleteRequest {
    /// The tenant queue.
    pub tenant: String,
    /// The job being completed.
    pub job_id: String,
    /// The lease token proving ownership.
    pub lease_token: String,
    /// The result to record.
    pub result: JobResult,
}

/// Control plane → runner: whether the completion was recorded (false if the
/// lease was already lost — token mismatch or the job had become terminal).
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct CompleteResponse {
    /// Whether the result was recorded.
    pub recorded: bool,
}

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

    #[test]
    fn job_round_trips_through_json() {
        let job = Job::plc_provision("job_1", "acme", "64f0aa", InputRef::blob("sha256:abc"), 180);
        let json = serde_json::to_string(&job).expect("serialize");
        let back: Job = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(job, back);
        // Enum wire forms are the kebab/lowercase the contract documents.
        assert!(json.contains("\"type\":\"plc-provision\""));
        assert!(json.contains("\"executor\":\"docker\""));
    }

    #[test]
    fn parses_the_design_doc_plc_provision_toml() {
        // The exact shape from docs/DESIGN.md §5 (wrapped in a [job] table).
        #[derive(Deserialize)]
        struct JobFile {
            job: Job,
        }
        let src = r#"
            [job]
            id = "job_01H"
            type = "plc-provision"
            tenant = "acme"
            target_id = "64f0"
            executor = "docker"
            labels = ["arch=amd64"]
            timeout_secs = 180

            [job.inputs]
            program = { blob = "sha256:deadbeef" }

            [job.runtime]
            image = "openplc:latest"
            memory = "512m"
            cpus = "0.5"
            network = "isolated"

            [job.collect]
            ics_probe = true
            dast = { max_crawl_depth = 2 }
        "#;
        let file: JobFile = toml::from_str(src).expect("parse job toml");
        let job = file.job;
        assert_eq!(job.job_type, JobType::PlcProvision);
        assert_eq!(job.executor, Executor::Docker);
        assert_eq!(job.labels, vec!["arch=amd64".to_string()]);
        assert_eq!(
            job.inputs.get("program").and_then(|i| i.blob.as_deref()),
            Some("sha256:deadbeef")
        );
        assert_eq!(job.runtime.image.as_deref(), Some("openplc:latest"));
        assert!(job.collect.ics_probe);
        assert_eq!(job.collect.dast.map(|d| d.max_crawl_depth), Some(2));
    }

    #[test]
    fn qemu_boot_runtime_fields_parse() {
        #[derive(Deserialize)]
        struct JobFile {
            job: Job,
        }
        let src = r#"
            [job]
            id = "j2"
            type = "qemu-boot"
            tenant = "acme"
            target_id = "t"
            executor = "docker"
            labels = ["kvm=true"]
            timeout_secs = 600
            [job.inputs]
            firmware = { blob = "sha256:cafe" }
            [job.runtime]
            machine = "virt"
            arch = "arm"
            memory = "1g"
        "#;
        let file: JobFile = toml::from_str(src).expect("parse");
        assert_eq!(file.job.job_type, JobType::QemuBoot);
        assert_eq!(file.job.runtime.arch.as_deref(), Some("arm"));
        assert_eq!(
            file.job
                .inputs
                .get("firmware")
                .and_then(|i| i.blob.as_deref()),
            Some("sha256:cafe")
        );
    }

    #[test]
    fn status_terminality() {
        assert!(JobStatus::Succeeded.is_terminal());
        assert!(JobStatus::Expired.is_terminal());
        assert!(!JobStatus::Queued.is_terminal());
        assert!(!JobStatus::Running.is_terminal());
    }

    #[test]
    fn result_constructors() {
        assert_eq!(JobResult::succeeded("j").status, Some(JobStatus::Succeeded));
        let f = JobResult::failed("j", "boom");
        assert_eq!(f.status, Some(JobStatus::Failed));
        assert_eq!(f.error.as_deref(), Some("boom"));
    }
}
