//! v1.2 Workflows M2 — durable orchestrator integration tests.
//!
//! Two layers, both DB-gated (skip cleanly when `DATABASE_URL` is unset):
//!
//!   * **State machine** — drive `claim_ready_step` → `complete_step_and_advance`
//!     in a deterministic loop (no executor, feeding canned outcomes) to pin the
//!     durable DAG advance: linear chains, parallel fan-out/fan-in, retry,
//!     on_error fail vs continue, stale-token idempotency, and stale-lease
//!     reclaim.
//!   * **End-to-end** — a real orchestrator `tick()` with a fake `ExecutorClient`
//!     and real `scripts` rows, asserting steps resolve + execute + advance to a
//!     terminal run.

#![allow(clippy::too_many_lines)]

use std::sync::{Arc, LazyLock};

use async_trait::async_trait;
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, ExecStats};
use picloud_manager_core::repo::PostgresScriptRepository;
use picloud_manager_core::workflow_orchestrator::{
    WorkflowOrchestrator, WorkflowOrchestratorConfig,
};
use picloud_manager_core::workflow_repo::{
    claim_ready_step, complete_step_and_advance, get_run, list_run_steps, list_runs_for_workflow,
    reclaim_stale_steps, start_run, AdvanceResult, ClaimedStep, NewWorkflowRun, StepOutcome,
};
use picloud_manager_core::workflow_service::WorkflowServiceImpl;
use picloud_orchestrator_core::{ExecutionGate, ExecutorClient, ScriptIdentity};
use picloud_shared::workflow::{
    OnError, RunStatus, StepStatus, WorkflowBackoff, WorkflowDefinition, WorkflowRetry,
    WorkflowStepDef,
};
use picloud_shared::{AppId, ExecutionLogSink, LogSinkError, WorkflowId};
use picloud_shared::{RequestId, ScriptId, WorkflowService};
use serde_json::{json, Value};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use std::time::Duration as StdDuration;
use uuid::Uuid;

/// `claim_ready_step` is deliberately **global** (one production orchestrator
/// claims every app's ready steps). These tests share one DB, so a sibling
/// test's concurrent claim would steal steps from the run under assertion. This
/// process-wide lock serializes the claim-driving tests — each still creates
/// its own app/workflow/run, and each finishes with no `ready` steps, so once
/// serialized they don't interfere. (See the shared-DB test-harness note.)
static CLAIM_LOCK: LazyLock<tokio::sync::Mutex<()>> = LazyLock::new(|| tokio::sync::Mutex::new(()));

/// Acquire the serialization lock AND clear any leftover runs/steps so the
/// global `claim_ready_step`/`reclaim_stale_steps` see only this test's rows.
/// (The shared dev DB accumulates rows across runs; `workflow_runs` CASCADEs to
/// its steps.) Returns the held guard.
async fn lock_and_reset(pool: &PgPool) -> tokio::sync::MutexGuard<'static, ()> {
    let guard = CLAIM_LOCK.lock().await;
    sqlx::query("DELETE FROM workflow_runs")
        .execute(pool)
        .await
        .expect("reset workflow_runs");
    guard
}

async fn pool_or_skip() -> Option<PgPool> {
    let Ok(url) = std::env::var("DATABASE_URL") else {
        eprintln!("workflow_orchestrator: DATABASE_URL unset — skipping");
        return None;
    };
    let pool = PgPoolOptions::new()
        .max_connections(4)
        .connect(&url)
        .await
        .expect("connect to DATABASE_URL");
    sqlx::migrate!("./migrations")
        .run(&pool)
        .await
        .expect("apply migrations");
    Some(pool)
}

fn step(name: &str, function: &str, deps: &[&str]) -> WorkflowStepDef {
    WorkflowStepDef {
        name: name.into(),
        function: Some(function.into()),
        workflow: None,
        input: Value::Null,
        depends_on: deps.iter().map(|s| (*s).to_string()).collect(),
        when: None,
        retry: None,
        on_error: OnError::Fail,
    }
}

/// Insert an app + a workflow definition; return `(app_id, workflow_id)`.
async fn seed_workflow(pool: &PgPool, def: &WorkflowDefinition) -> (AppId, WorkflowId) {
    let sfx = Uuid::new_v4().simple().to_string();
    let grp: (Uuid,) =
        sqlx::query_as("INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id")
            .bind(format!("wf-grp-{sfx}"))
            .fetch_one(pool)
            .await
            .expect("insert group");
    let app: (Uuid,) =
        sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id")
            .bind(format!("wf-app-{sfx}"))
            .bind(grp.0)
            .fetch_one(pool)
            .await
            .expect("insert app");
    let def_json = serde_json::to_value(def).unwrap();
    let wf: (Uuid,) = sqlx::query_as(
        "INSERT INTO workflows (app_id, name, definition) VALUES ($1, $2, $3) RETURNING id",
    )
    .bind(app.0)
    .bind(format!("wf-{sfx}"))
    .bind(def_json)
    .fetch_one(pool)
    .await
    .expect("insert workflow");
    (app.0.into(), wf.0.into())
}

fn new_run(app_id: AppId, wf_id: WorkflowId) -> NewWorkflowRun {
    NewWorkflowRun {
        workflow_id: wf_id,
        app_id,
        input: json!({ "seed": true }),
        root_execution_id: Uuid::new_v4(),
        workflow_depth: 0,
        parent_run_id: None,
        parent_step_id: None,
    }
}

/// Drive the run to a terminal state by claiming + completing steps, deciding
/// each outcome via `decide` (keyed on step name + current attempt).
async fn run_to_terminal<F>(pool: &PgPool, mut decide: F)
where
    F: FnMut(&ClaimedStep) -> StepOutcome,
{
    // Bounded so a bug can't spin forever.
    for _ in 0..200 {
        let Some(claimed) = claim_ready_step(pool).await.expect("claim") else {
            break;
        };
        let outcome = decide(&claimed);
        complete_step_and_advance(pool, &claimed, outcome)
            .await
            .expect("advance");
    }
}

fn ok(body: Value) -> StepOutcome {
    StepOutcome::Succeeded(body)
}

fn fail(msg: &str) -> StepOutcome {
    StepOutcome::Failed {
        error: msg.into(),
        retry_delay: chrono::Duration::zero(),
    }
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn seeds_roots_ready_and_completes_linear_chain() {
    let Some(pool) = pool_or_skip().await else {
        return;
    };
    let _claim_guard = lock_and_reset(&pool).await;
    // a -> b -> c
    let def = WorkflowDefinition {
        steps: vec![
            step("a", "fn_a", &[]),
            step("b", "fn_b", &["a"]),
            step("c", "fn_c", &["b"]),
        ],
    };
    let (app, wf) = seed_workflow(&pool, &def).await;
    let run = start_run(&pool, new_run(app, wf), &def)
        .await
        .expect("start");

    // Root `a` starts ready; b, c pending.
    let steps = list_run_steps(&pool, app, run).await.unwrap();
    let by = |n: &str| steps.iter().find(|s| s.step_name == n).unwrap().status;
    assert_eq!(by("a"), StepStatus::Ready);
    assert_eq!(by("b"), StepStatus::Pending);
    assert_eq!(by("c"), StepStatus::Pending);

    run_to_terminal(&pool, |c| ok(json!({ "step": c.step_name }))).await;

    let r = get_run(&pool, app, run).await.unwrap().unwrap();
    assert_eq!(r.status, RunStatus::Succeeded);
    assert_eq!(
        r.output,
        Some(json!({
            "a": { "step": "a" },
            "b": { "step": "b" },
            "c": { "step": "c" },
        }))
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn parallel_fan_out_and_fan_in() {
    let Some(pool) = pool_or_skip().await else {
        return;
    };
    let _claim_guard = lock_and_reset(&pool).await;
    // a -> {b, c} -> d (diamond).
    let def = WorkflowDefinition {
        steps: vec![
            step("a", "fn_a", &[]),
            step("b", "fn_b", &["a"]),
            step("c", "fn_c", &["a"]),
            step("d", "fn_d", &["b", "c"]),
        ],
    };
    let (app, wf) = seed_workflow(&pool, &def).await;
    let run = start_run(&pool, new_run(app, wf), &def)
        .await
        .expect("start");

    // After `a` completes, BOTH b and c must be claimable before d.
    let a = claim_ready_step(&pool).await.unwrap().expect("claim a");
    assert_eq!(a.step_name, "a");
    complete_step_and_advance(&pool, &a, ok(json!("a")))
        .await
        .unwrap();

    let s1 = claim_ready_step(&pool)
        .await
        .unwrap()
        .expect("first parallel");
    let s2 = claim_ready_step(&pool)
        .await
        .unwrap()
        .expect("second parallel");
    let mut names = [s1.step_name.clone(), s2.step_name.clone()];
    names.sort();
    assert_eq!(names, ["b".to_string(), "c".to_string()]);
    // d must NOT be claimable yet (fan-in waits).
    assert!(claim_ready_step(&pool).await.unwrap().is_none());

    complete_step_and_advance(&pool, &s1, ok(json!("x")))
        .await
        .unwrap();
    // Still waiting on the other parallel branch.
    assert!(claim_ready_step(&pool).await.unwrap().is_none());
    complete_step_and_advance(&pool, &s2, ok(json!("y")))
        .await
        .unwrap();

    let d = claim_ready_step(&pool)
        .await
        .unwrap()
        .expect("d after fan-in");
    assert_eq!(d.step_name, "d");
    complete_step_and_advance(&pool, &d, ok(json!("d")))
        .await
        .unwrap();

    let r = get_run(&pool, app, run).await.unwrap().unwrap();
    assert_eq!(r.status, RunStatus::Succeeded);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn retry_then_succeed() {
    let Some(pool) = pool_or_skip().await else {
        return;
    };
    let _claim_guard = lock_and_reset(&pool).await;
    let mut a = step("a", "fn_a", &[]);
    a.retry = Some(WorkflowRetry {
        max_attempts: 3,
        backoff: WorkflowBackoff::Constant,
        base_ms: 0, // no delay → immediately re-claimable
    });
    let def = WorkflowDefinition { steps: vec![a] };
    let (app, wf) = seed_workflow(&pool, &def).await;
    let run = start_run(&pool, new_run(app, wf), &def)
        .await
        .expect("start");

    // First claim fails (attempt 1) → retried.
    let c1 = claim_ready_step(&pool).await.unwrap().expect("attempt 1");
    assert_eq!(c1.attempt, 1);
    let res = complete_step_and_advance(&pool, &c1, fail("boom"))
        .await
        .unwrap();
    assert_eq!(res, AdvanceResult::Retried);

    // Second claim (attempt 2) succeeds → run done.
    let c2 = claim_ready_step(&pool).await.unwrap().expect("attempt 2");
    assert_eq!(c2.attempt, 2);
    complete_step_and_advance(&pool, &c2, ok(json!("recovered")))
        .await
        .unwrap();

    let r = get_run(&pool, app, run).await.unwrap().unwrap();
    assert_eq!(r.status, RunStatus::Succeeded);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn on_error_fail_fails_the_run() {
    let Some(pool) = pool_or_skip().await else {
        return;
    };
    let _claim_guard = lock_and_reset(&pool).await;
    // a (fail) -> b; a has no retry and on_error=fail → run fails, b never runs.
    let def = WorkflowDefinition {
        steps: vec![step("a", "fn_a", &[]), step("b", "fn_b", &["a"])],
    };
    let (app, wf) = seed_workflow(&pool, &def).await;
    let run = start_run(&pool, new_run(app, wf), &def)
        .await
        .expect("start");

    run_to_terminal(&pool, |c| {
        if c.step_name == "a" {
            fail("kaboom")
        } else {
            ok(json!("b"))
        }
    })
    .await;

    let r = get_run(&pool, app, run).await.unwrap().unwrap();
    assert_eq!(r.status, RunStatus::Failed);
    assert!(r.error.unwrap().contains('a'));
    let steps = list_run_steps(&pool, app, run).await.unwrap();
    let b = steps.iter().find(|s| s.step_name == "b").unwrap();
    assert_eq!(b.status, StepStatus::Pending); // never promoted
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn on_error_continue_run_succeeds() {
    let Some(pool) = pool_or_skip().await else {
        return;
    };
    let _claim_guard = lock_and_reset(&pool).await;
    let mut a = step("a", "fn_a", &[]);
    a.on_error = OnError::Continue;
    let def = WorkflowDefinition {
        steps: vec![a, step("b", "fn_b", &["a"])],
    };
    let (app, wf) = seed_workflow(&pool, &def).await;
    let run = start_run(&pool, new_run(app, wf), &def)
        .await
        .expect("start");

    run_to_terminal(&pool, |c| {
        if c.step_name == "a" {
            fail("soft")
        } else {
            ok(json!("b-ran"))
        }
    })
    .await;

    let r = get_run(&pool, app, run).await.unwrap().unwrap();
    assert_eq!(
        r.status,
        RunStatus::Succeeded,
        "continue lets the run finish"
    );
    assert_eq!(r.output, Some(json!({ "b": "b-ran" })));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn double_complete_is_idempotent() {
    let Some(pool) = pool_or_skip().await else {
        return;
    };
    let _claim_guard = lock_and_reset(&pool).await;
    let def = WorkflowDefinition {
        steps: vec![step("a", "fn_a", &[])],
    };
    let (app, wf) = seed_workflow(&pool, &def).await;
    let run = start_run(&pool, new_run(app, wf), &def)
        .await
        .expect("start");

    let c = claim_ready_step(&pool).await.unwrap().expect("claim");
    let first = complete_step_and_advance(&pool, &c, ok(json!("first")))
        .await
        .unwrap();
    assert_eq!(first, AdvanceResult::Advanced);

    // Replaying the SAME claim (stale token, lease long gone) writes nothing.
    let second = complete_step_and_advance(&pool, &c, ok(json!("SECOND")))
        .await
        .unwrap();
    assert_eq!(second, AdvanceResult::Stale);

    let steps = list_run_steps(&pool, app, run).await.unwrap();
    assert_eq!(steps[0].output, Some(json!("first")), "first write wins");
    let r = get_run(&pool, app, run).await.unwrap().unwrap();
    assert_eq!(r.status, RunStatus::Succeeded);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stale_lease_is_reclaimed() {
    let Some(pool) = pool_or_skip().await else {
        return;
    };
    let _claim_guard = lock_and_reset(&pool).await;
    let def = WorkflowDefinition {
        steps: vec![step("a", "fn_a", &[])],
    };
    let (app, wf) = seed_workflow(&pool, &def).await;
    let run = start_run(&pool, new_run(app, wf), &def)
        .await
        .expect("start");

    let c = claim_ready_step(&pool).await.unwrap().expect("claim");
    // Nothing else claimable while `a` is leased.
    assert!(claim_ready_step(&pool).await.unwrap().is_none());

    // Backdate the lease so it's past the (0s) visibility window.
    sqlx::query(
        "UPDATE workflow_run_steps SET claimed_at = NOW() - INTERVAL '1 hour' WHERE id = $1",
    )
    .bind(c.step_id.into_inner())
    .execute(&pool)
    .await
    .unwrap();
    let reclaimed = reclaim_stale_steps(&pool, 0).await.unwrap();
    assert_eq!(reclaimed, 1);

    // Now claimable again (a fresh attempt).
    let c2 = claim_ready_step(&pool)
        .await
        .unwrap()
        .expect("re-claim after reclaim");
    assert_eq!(c2.step_name, "a");
    // The crashed attempt produced no outcome, so it must not consume the retry
    // budget: after claim(→1), reclaim(→0), re-claim(→1) the attempt is 1, not 2.
    assert_eq!(c2.attempt, 1, "reclaim must not burn the retry budget");
    complete_step_and_advance(&pool, &c2, ok(json!("done")))
        .await
        .unwrap();
    let r = get_run(&pool, app, run).await.unwrap().unwrap();
    assert_eq!(r.status, RunStatus::Succeeded);
}

// ---- end-to-end: a real orchestrator tick with a fake executor ------------

struct FakeExecutor {
    calls: std::sync::Mutex<Vec<String>>,
}

#[async_trait]
impl ExecutorClient for FakeExecutor {
    async fn execute(
        &self,
        _source: &str,
        req: ExecRequest,
        _timeout: StdDuration,
    ) -> Result<ExecResponse, ExecError> {
        self.calls.lock().unwrap().push(req.script_name.clone());
        Ok(ExecResponse {
            status_code: 200,
            headers: std::collections::BTreeMap::new(),
            body: json!({ "ran": req.script_name }),
            logs: vec![],
            stats: ExecStats::default(),
        })
    }

    async fn execute_with_identity(
        &self,
        _identity: ScriptIdentity,
        source: &str,
        req: ExecRequest,
        timeout: StdDuration,
    ) -> Result<ExecResponse, ExecError> {
        self.execute(source, req, timeout).await
    }
}

struct NoopLogSink;
#[async_trait]
impl ExecutionLogSink for NoopLogSink {
    async fn record(&self, _log: picloud_shared::ExecutionLog) -> Result<(), LogSinkError> {
        Ok(())
    }
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn end_to_end_tick_executes_functions_and_completes() {
    let Some(pool) = pool_or_skip().await else {
        return;
    };
    let _claim_guard = lock_and_reset(&pool).await;
    // a -> b; both bound to real scripts (fn_a, fn_b) so name resolution works.
    let def = WorkflowDefinition {
        steps: vec![step("a", "fn_a", &[]), step("b", "fn_b", &["a"])],
    };
    let (app, wf) = seed_workflow(&pool, &def).await;
    for fn_name in ["fn_a", "fn_b"] {
        sqlx::query("INSERT INTO scripts (name, source, app_id) VALUES ($1, 'x', $2)")
            .bind(fn_name)
            .bind(app.into_inner())
            .execute(&pool)
            .await
            .expect("insert script");
    }
    let run = start_run(&pool, new_run(app, wf), &def)
        .await
        .expect("start");

    let executor = Arc::new(FakeExecutor {
        calls: std::sync::Mutex::new(vec![]),
    });
    let orch = WorkflowOrchestrator {
        pool: pool.clone(),
        scripts: Arc::new(PostgresScriptRepository::new(pool.clone())),
        executor: executor.clone(),
        gate: Arc::new(ExecutionGate::new(8)),
        log_sink: Arc::new(NoopLogSink),
        config: WorkflowOrchestratorConfig::default(),
    };

    // Drive ticks until the run is terminal (bounded).
    for _ in 0..50 {
        orch.tick_once().await;
        let r = get_run(&pool, app, run).await.unwrap().unwrap();
        if r.status.is_terminal() {
            break;
        }
    }

    let r = get_run(&pool, app, run).await.unwrap().unwrap();
    assert_eq!(r.status, RunStatus::Succeeded);
    let calls = executor.calls.lock().unwrap().clone();
    assert!(calls.contains(&"fn_a".to_string()));
    assert!(calls.contains(&"fn_b".to_string()));
    assert_eq!(
        r.output,
        Some(json!({ "a": { "ran": "fn_a" }, "b": { "ran": "fn_b" } }))
    );
}

// ===========================================================================
// M3 — conditional `when` + input templating (end-to-end via the orchestrator).
// ===========================================================================

/// An executor that returns a canned body per script name (default
/// `{ "ran": name }`) and captures every `(script_name, request_body)` — so a
/// test can assert what input a downstream step actually received.
struct ScriptedExecutor {
    outputs: std::collections::HashMap<String, Value>,
    captured: std::sync::Mutex<Vec<(String, Value)>>,
}

impl ScriptedExecutor {
    fn new(outputs: &[(&str, Value)]) -> Self {
        Self {
            outputs: outputs
                .iter()
                .map(|(k, v)| ((*k).to_string(), v.clone()))
                .collect(),
            captured: std::sync::Mutex::new(vec![]),
        }
    }
    fn body_for(&self, name: &str) -> Option<Value> {
        self.captured
            .lock()
            .unwrap()
            .iter()
            .find(|(n, _)| n == name)
            .map(|(_, b)| b.clone())
    }
}

#[async_trait]
impl ExecutorClient for ScriptedExecutor {
    async fn execute(
        &self,
        _source: &str,
        req: ExecRequest,
        _timeout: StdDuration,
    ) -> Result<ExecResponse, ExecError> {
        self.captured
            .lock()
            .unwrap()
            .push((req.script_name.clone(), req.body.clone()));
        let body = self
            .outputs
            .get(&req.script_name)
            .cloned()
            .unwrap_or_else(|| json!({ "ran": req.script_name }));
        Ok(ExecResponse {
            status_code: 200,
            headers: std::collections::BTreeMap::new(),
            body,
            logs: vec![],
            stats: ExecStats::default(),
        })
    }

    async fn execute_with_identity(
        &self,
        _identity: ScriptIdentity,
        source: &str,
        req: ExecRequest,
        timeout: StdDuration,
    ) -> Result<ExecResponse, ExecError> {
        self.execute(source, req, timeout).await
    }
}

async fn seed_scripts(pool: &PgPool, app: AppId, names: &[&str]) {
    for name in names {
        sqlx::query("INSERT INTO scripts (name, source, app_id) VALUES ($1, 'x', $2)")
            .bind(name)
            .bind(app.into_inner())
            .execute(pool)
            .await
            .expect("insert script");
    }
}

/// Drive a real orchestrator to terminal using `executor` + `config`.
async fn drive_with(
    pool: &PgPool,
    executor: Arc<dyn ExecutorClient>,
    app: AppId,
    run: picloud_shared::WorkflowRunId,
    config: WorkflowOrchestratorConfig,
) {
    let orch = WorkflowOrchestrator {
        pool: pool.clone(),
        scripts: Arc::new(PostgresScriptRepository::new(pool.clone())),
        executor,
        gate: Arc::new(ExecutionGate::new(8)),
        log_sink: Arc::new(NoopLogSink),
        config,
    };
    for _ in 0..80 {
        orch.tick_once().await;
        if get_run(pool, app, run)
            .await
            .unwrap()
            .unwrap()
            .status
            .is_terminal()
        {
            break;
        }
    }
}

/// Drive to terminal with the default config.
async fn drive_to_terminal(
    pool: &PgPool,
    executor: Arc<dyn ExecutorClient>,
    app: AppId,
    run: picloud_shared::WorkflowRunId,
) {
    drive_with(
        pool,
        executor,
        app,
        run,
        WorkflowOrchestratorConfig::default(),
    )
    .await;
}

/// A `workflow`-kind (nested sub-workflow) step.
fn wf_step(name: &str, workflow: &str, deps: &[&str]) -> WorkflowStepDef {
    WorkflowStepDef {
        name: name.into(),
        function: None,
        workflow: Some(workflow.into()),
        input: Value::Null,
        depends_on: deps.iter().map(|s| (*s).to_string()).collect(),
        when: None,
        retry: None,
        on_error: OnError::Fail,
    }
}

/// Create just a group + app; return the app id.
async fn seed_app(pool: &PgPool) -> AppId {
    let sfx = Uuid::new_v4().simple().to_string();
    let grp: (Uuid,) =
        sqlx::query_as("INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id")
            .bind(format!("wf-grp-{sfx}"))
            .fetch_one(pool)
            .await
            .expect("insert group");
    let app: (Uuid,) =
        sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id")
            .bind(format!("wf-app-{sfx}"))
            .bind(grp.0)
            .fetch_one(pool)
            .await
            .expect("insert app");
    app.0.into()
}

/// Insert a workflow with a specific name into an existing app.
async fn seed_named_workflow(
    pool: &PgPool,
    app: AppId,
    name: &str,
    def: &WorkflowDefinition,
) -> WorkflowId {
    let def_json = serde_json::to_value(def).unwrap();
    let wf: (Uuid,) = sqlx::query_as(
        "INSERT INTO workflows (app_id, name, definition) VALUES ($1, $2, $3) RETURNING id",
    )
    .bind(app.into_inner())
    .bind(name)
    .bind(def_json)
    .fetch_one(pool)
    .await
    .expect("insert named workflow");
    wf.0.into()
}

fn run_with_input(app: AppId, wf: WorkflowId, input: Value) -> NewWorkflowRun {
    NewWorkflowRun {
        workflow_id: wf,
        app_id: app,
        input,
        root_execution_id: Uuid::new_v4(),
        workflow_depth: 0,
        parent_run_id: None,
        parent_step_id: None,
    }
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn when_false_skips_step() {
    let Some(pool) = pool_or_skip().await else {
        return;
    };
    let _claim_guard = lock_and_reset(&pool).await;
    // a -> b, but b runs only `when input.flag` (false here) → b is skipped.
    let mut b = step("b", "fn_b", &["a"]);
    b.when = Some("input.flag".into());
    let def = WorkflowDefinition {
        steps: vec![step("a", "fn_a", &[]), b],
    };
    let (app, wf) = seed_workflow(&pool, &def).await;
    seed_scripts(&pool, app, &["fn_a", "fn_b"]).await;
    let run = start_run(
        &pool,
        run_with_input(app, wf, json!({ "flag": false })),
        &def,
    )
    .await
    .expect("start");

    let executor = Arc::new(ScriptedExecutor::new(&[]));
    drive_to_terminal(&pool, executor.clone(), app, run).await;

    let r = get_run(&pool, app, run).await.unwrap().unwrap();
    assert_eq!(r.status, RunStatus::Succeeded);
    let steps = list_run_steps(&pool, app, run).await.unwrap();
    let by = |n: &str| steps.iter().find(|s| s.step_name == n).unwrap().status;
    assert_eq!(by("a"), StepStatus::Succeeded);
    assert_eq!(by("b"), StepStatus::Skipped);
    // A skipped step is never executed.
    assert!(
        executor.body_for("fn_b").is_none(),
        "skipped step must not run"
    );
    // Its output is omitted from the run output.
    assert_eq!(r.output, Some(json!({ "a": { "ran": "fn_a" } })));
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn step_output_flows_into_downstream_input() {
    let Some(pool) = pool_or_skip().await else {
        return;
    };
    let _claim_guard = lock_and_reset(&pool).await;
    // b's input pulls a field out of a's output, type-preserved.
    let mut b = step("b", "fn_b", &["a"]);
    b.input = json!({ "v": "{{ steps.a.output.n }}", "label": "n={{ steps.a.output.n }}" });
    let def = WorkflowDefinition {
        steps: vec![step("a", "fn_a", &[]), b],
    };
    let (app, wf) = seed_workflow(&pool, &def).await;
    seed_scripts(&pool, app, &["fn_a", "fn_b"]).await;
    let run = start_run(&pool, new_run(app, wf), &def)
        .await
        .expect("start");

    // fn_a outputs { n: 7 }.
    let executor = Arc::new(ScriptedExecutor::new(&[("fn_a", json!({ "n": 7 }))]));
    drive_to_terminal(&pool, executor.clone(), app, run).await;

    let r = get_run(&pool, app, run).await.unwrap().unwrap();
    assert_eq!(r.status, RunStatus::Succeeded);
    // b received the resolved input: exact-single-ref preserves the number 7,
    // the embedded ref interpolates as text.
    assert_eq!(
        executor.body_for("fn_b"),
        Some(json!({ "v": 7, "label": "n=7" }))
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn missing_input_ref_fails_the_step() {
    let Some(pool) = pool_or_skip().await else {
        return;
    };
    let _claim_guard = lock_and_reset(&pool).await;
    let mut b = step("b", "fn_b", &["a"]);
    b.input = json!({ "v": "{{ steps.a.output.does_not_exist }}" });
    let def = WorkflowDefinition {
        steps: vec![step("a", "fn_a", &[]), b],
    };
    let (app, wf) = seed_workflow(&pool, &def).await;
    seed_scripts(&pool, app, &["fn_a", "fn_b"]).await;
    let run = start_run(&pool, new_run(app, wf), &def)
        .await
        .expect("start");

    // fn_a outputs { n: 7 } — no `does_not_exist`, so b's input resolve fails.
    let executor = Arc::new(ScriptedExecutor::new(&[("fn_a", json!({ "n": 7 }))]));
    drive_to_terminal(&pool, executor.clone(), app, run).await;

    let r = get_run(&pool, app, run).await.unwrap().unwrap();
    assert_eq!(
        r.status,
        RunStatus::Failed,
        "missing input ref fails the run"
    );
    assert!(executor.body_for("fn_b").is_none(), "b never executes");
    let steps = list_run_steps(&pool, app, run).await.unwrap();
    let bstep = steps.iter().find(|s| s.step_name == "b").unwrap();
    assert_eq!(bstep.status, StepStatus::Failed);
    assert!(bstep.error.as_deref().unwrap_or("").contains("input"));
}

// ===========================================================================
// M4 — nested sub-workflows.
// ===========================================================================

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn nested_sub_workflow_runs_and_output_flows_to_parent() {
    let Some(pool) = pool_or_skip().await else {
        return;
    };
    let _claim_guard = lock_and_reset(&pool).await;
    let app = seed_app(&pool).await;

    // Child workflow: one function step producing { answer: 42 }.
    let child_def = WorkflowDefinition {
        steps: vec![step("leaf", "fn_leaf", &[])],
    };
    seed_named_workflow(&pool, app, "child", &child_def).await;

    // Parent: a `workflow`-kind step "call" → child, then "after" consuming its
    // output. The child run's output is { leaf: <leaf output> }, so
    // steps.call.output.leaf.answer reaches through the nesting.
    let mut after = step("after", "fn_after", &["call"]);
    after.input = json!({ "got": "{{ steps.call.output.leaf.answer }}" });
    let parent_def = WorkflowDefinition {
        steps: vec![wf_step("call", "child", &[]), after],
    };
    let parent_wf = seed_named_workflow(&pool, app, "parent", &parent_def).await;
    seed_scripts(&pool, app, &["fn_leaf", "fn_after"]).await;

    let run = start_run(&pool, new_run(app, parent_wf), &parent_def)
        .await
        .expect("start");

    let executor = Arc::new(ScriptedExecutor::new(&[(
        "fn_leaf",
        json!({ "answer": 42 }),
    )]));
    drive_with(
        &pool,
        executor.clone(),
        app,
        run,
        WorkflowOrchestratorConfig::default(),
    )
    .await;

    let r = get_run(&pool, app, run).await.unwrap().unwrap();
    assert_eq!(r.status, RunStatus::Succeeded);
    // The child's output flowed into the parent step, then into `after`'s input.
    assert_eq!(executor.body_for("fn_after"), Some(json!({ "got": 42 })));
    // The parent step "call" carries the child run's output.
    let steps = list_run_steps(&pool, app, run).await.unwrap();
    let call = steps.iter().find(|s| s.step_name == "call").unwrap();
    assert_eq!(call.status, StepStatus::Succeeded);
    assert_eq!(call.output, Some(json!({ "leaf": { "answer": 42 } })));
    assert!(
        call.child_run_id.is_some(),
        "parent step links its child run"
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn nesting_depth_ceiling_fails_the_run() {
    let Some(pool) = pool_or_skip().await else {
        return;
    };
    let _claim_guard = lock_and_reset(&pool).await;
    let app = seed_app(&pool).await;

    // A workflow that nests into ITSELF — bounded only by the depth ceiling.
    let def = WorkflowDefinition {
        steps: vec![wf_step("recurse", "loopwf", &[])],
    };
    let wf = seed_named_workflow(&pool, app, "loopwf", &def).await;
    let run = start_run(&pool, new_run(app, wf), &def)
        .await
        .expect("start");

    // max_depth = 2 → depth 0 and 1 spawn children; depth 2's spawn is refused.
    let config = WorkflowOrchestratorConfig {
        max_depth: 2,
        ..WorkflowOrchestratorConfig::default()
    };
    let executor = Arc::new(ScriptedExecutor::new(&[]));
    drive_with(&pool, executor, app, run, config).await;

    let r = get_run(&pool, app, run).await.unwrap().unwrap();
    assert_eq!(
        r.status,
        RunStatus::Failed,
        "self-nesting run fails at the depth ceiling, not infinitely"
    );
}

// ===========================================================================
// M5 — the SDK/API-shared service entry (`WorkflowServiceImpl::start`).
// ===========================================================================

fn anon_cx(app: AppId) -> picloud_shared::SdkCallCx {
    let execution_id = picloud_shared::ExecutionId::new();
    picloud_shared::SdkCallCx {
        app_id: app,
        script_id: ScriptId::new(),
        principal: None,
        execution_id,
        request_id: RequestId::new(),
        trigger_depth: 0,
        root_execution_id: execution_id,
        is_dead_letter_handler: false,
        event: None,
    }
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn workflow_service_start_seeds_a_run() {
    let Some(pool) = pool_or_skip().await else {
        return;
    };
    let _claim_guard = lock_and_reset(&pool).await;
    let app = seed_app(&pool).await;
    let def = WorkflowDefinition {
        steps: vec![step("only", "fn_only", &[])],
    };
    seed_named_workflow(&pool, app, "svc_wf", &def).await;

    let svc = WorkflowServiceImpl::new(pool.clone());
    let cx = anon_cx(app);

    // start seeds a durable run with the given input + its steps.
    let run_id = svc
        .start(&cx, "svc_wf", json!({ "k": "v" }))
        .await
        .expect("start");
    let r = get_run(&pool, app, run_id).await.unwrap().unwrap();
    assert_eq!(r.input, json!({ "k": "v" }));
    let steps = list_run_steps(&pool, app, run_id).await.unwrap();
    assert_eq!(steps.len(), 1);
    assert_eq!(steps[0].step_name, "only");
    assert_eq!(steps[0].status, StepStatus::Ready); // a root

    // it shows up in the run-history list.
    let runs = list_runs_for_workflow(&pool, app, r.workflow_id, 10)
        .await
        .unwrap();
    assert_eq!(runs.len(), 1);

    // an unknown workflow name is a not-found error.
    assert!(svc.start(&cx, "ghost", Value::Null).await.is_err());
}
