//! Workflow definition + status DTOs (v1.2 Workflows).
//!
//! Pure data shared across three consumers: the `pic` CLI (which builds a
//! [`WorkflowDefinition`] from the TOML manifest), the manager-core apply path
//! (which validates + stores it as JSONB), and the orchestrator (which executes
//! it). There is NO behavior here — the validator (`workflow_expr`), the input
//! resolver (`workflow_template`), and the durable orchestrator all live
//! server-side in `manager-core`, the only crate that touches Postgres.
//!
//! The `WorkflowService` trait is the injected SDK seam for `workflow::start`
//! (M5): a script (or the admin API) starts a run of a named workflow in the
//! caller's app. Same-app only — the owning workflow resolves from `cx.app_id`,
//! never a script-passed arg (the isolation boundary), mirroring `InvokeService`.

use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::{SdkCallCx, WorkflowRunId};

/// Backs the Rhai `workflow::start(name, input)` call and the admin run API.
#[async_trait]
pub trait WorkflowService: Send + Sync {
    /// Start a run of the workflow named `name` in `cx.app_id`, seeded with
    /// `input`. Returns the new run id. The durable orchestrator advances it.
    async fn start(
        &self,
        cx: &SdkCallCx,
        name: &str,
        input: serde_json::Value,
    ) -> Result<WorkflowRunId, WorkflowError>;
}

#[derive(Debug, Error)]
pub enum WorkflowError {
    #[error("workflow {0:?} not found")]
    NotFound(String),
    #[error("workflow {0:?} is disabled")]
    Disabled(String),
    /// The authenticated caller lacks `AppInvoke` on the app.
    #[error("forbidden")]
    Forbidden,
    #[error("workflow backend error: {0}")]
    Backend(String),
}

/// Default no-op: every call errors so a misconfigured bundle can't silently
/// no-op a `workflow::start`.
pub struct NoopWorkflowService;

#[async_trait]
impl WorkflowService for NoopWorkflowService {
    async fn start(
        &self,
        _cx: &SdkCallCx,
        _name: &str,
        _input: serde_json::Value,
    ) -> Result<WorkflowRunId, WorkflowError> {
        Err(WorkflowError::Backend(
            "workflow service is not configured".into(),
        ))
    }
}

/// A workflow's DAG: an ordered list of steps connected by `depends_on` edges.
/// Serialized to the `workflows.definition` JSONB column.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WorkflowDefinition {
    pub steps: Vec<WorkflowStepDef>,
}

/// One node in the DAG.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WorkflowStepDef {
    pub name: String,
    /// A function step invokes a script by name (resolved in the app's scope).
    /// Exactly one of `function` / `workflow` is set (enforced server-side).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub function: Option<String>,
    /// A workflow step starts a nested sub-workflow by name.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub workflow: Option<String>,
    /// Input mapping — a JSON value whose string leaves may carry `{{ … }}`
    /// references resolved at run time against the run input + prior step
    /// outputs (see `workflow_template`). Absent = the run input is passed
    /// through unchanged.
    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
    pub input: serde_json::Value,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub depends_on: Vec<String>,
    /// A JSON-predicate condition (see `workflow_expr`). When it evaluates
    /// false the step is `skipped` (and counts as satisfied-but-empty for its
    /// dependents). Absent = always run.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub when: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub retry: Option<WorkflowRetry>,
    #[serde(default)]
    pub on_error: OnError,
}

impl WorkflowStepDef {
    /// The resolved target of this step, or `None` if neither/both are set
    /// (a shape error caught by server-side validation).
    #[must_use]
    pub fn target(&self) -> Option<StepTarget<'_>> {
        match (self.function.as_deref(), self.workflow.as_deref()) {
            (Some(f), None) => Some(StepTarget::Function(f)),
            (None, Some(w)) => Some(StepTarget::Workflow(w)),
            _ => None,
        }
    }

    /// Max attempts for this step (1 = no retry), derived from its retry policy.
    #[must_use]
    pub fn max_attempts(&self) -> u32 {
        self.retry.as_ref().map_or(1, |r| r.max_attempts.max(1))
    }
}

/// A step's target — a function (script by name) or a nested sub-workflow.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepTarget<'a> {
    Function(&'a str),
    Workflow(&'a str),
}

/// What to do when a step exhausts its attempts.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OnError {
    /// Fail the whole run (default).
    #[default]
    Fail,
    /// Mark the step failed but let the run continue — dependents proceed and
    /// the run can still succeed.
    Continue,
}

/// Per-step retry policy.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkflowRetry {
    pub max_attempts: u32,
    #[serde(default)]
    pub backoff: WorkflowBackoff,
    #[serde(default = "default_base_ms")]
    pub base_ms: u32,
}

/// Backoff shape for step retries (self-contained so this DTO stays in `shared`;
/// the orchestrator maps it to its backoff computation).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkflowBackoff {
    Constant,
    Linear,
    #[default]
    Exponential,
}

/// Default per-step retry base delay (ms). Public so the CLI can invert the
/// pull round-trip (omit `base_ms` when it equals this) without duplicating the
/// literal.
#[must_use]
pub fn default_base_ms() -> u32 {
    500
}

/// Status of a workflow run (mirrors the `workflow_runs.status` CHECK).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunStatus {
    Pending,
    Running,
    Succeeded,
    Failed,
    Canceled,
}

impl RunStatus {
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Pending => "pending",
            Self::Running => "running",
            Self::Succeeded => "succeeded",
            Self::Failed => "failed",
            Self::Canceled => "canceled",
        }
    }

    #[must_use]
    pub fn is_terminal(self) -> bool {
        matches!(self, Self::Succeeded | Self::Failed | Self::Canceled)
    }

    #[must_use]
    #[allow(clippy::should_implement_trait)]
    pub fn from_str(s: &str) -> Option<Self> {
        Some(match s {
            "pending" => Self::Pending,
            "running" => Self::Running,
            "succeeded" => Self::Succeeded,
            "failed" => Self::Failed,
            "canceled" => Self::Canceled,
            _ => return None,
        })
    }
}

/// Status of a single step within a run (mirrors `workflow_run_steps.status`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StepStatus {
    Pending,
    Ready,
    Running,
    Succeeded,
    Failed,
    Skipped,
}

impl StepStatus {
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Pending => "pending",
            Self::Ready => "ready",
            Self::Running => "running",
            Self::Succeeded => "succeeded",
            Self::Failed => "failed",
            Self::Skipped => "skipped",
        }
    }

    /// A dependency is "satisfied" (a dependent may proceed) once it has
    /// succeeded OR been skipped (a false `when` — satisfied-but-empty).
    /// NB: a `failed` step whose `on_error = continue` also satisfies its
    /// dependents, but that depends on the step's *definition*, not its
    /// status alone — the orchestrator's advance logic folds that in.
    #[must_use]
    pub fn is_satisfied(self) -> bool {
        matches!(self, Self::Succeeded | Self::Skipped)
    }

    /// A step has reached a terminal status (no further transitions).
    #[must_use]
    pub fn is_terminal(self) -> bool {
        matches!(self, Self::Succeeded | Self::Failed | Self::Skipped)
    }

    #[must_use]
    #[allow(clippy::should_implement_trait)]
    pub fn from_str(s: &str) -> Option<Self> {
        Some(match s {
            "pending" => Self::Pending,
            "ready" => Self::Ready,
            "running" => Self::Running,
            "succeeded" => Self::Succeeded,
            "failed" => Self::Failed,
            "skipped" => Self::Skipped,
            _ => return None,
        })
    }
}
