//! The durable workflow orchestrator (v1.2 Workflows, M2).
//!
//! A single dedicated background worker — mirroring [`cron_scheduler`] — that
//! advances every in-flight workflow run step-by-step, durably, surviving
//! restarts. It is deliberately NOT folded into `dispatcher.rs`: a per-*run*
//! graph advance is its own transaction with its own lease semantics.
//!
//! Each tick has two phases:
//!
//!   **A. Claim + execute.** Up to a small batch of `ready`, due steps are
//!   claimed with the same `FOR UPDATE SKIP LOCKED` competing-consumer lease
//!   the queue uses ([`workflow_repo::claim_ready_step`]) — one execution-gate
//!   permit acquired per step *before* the claim, so the shared gate bounds
//!   real parallelism and no step is ever claimed without a slot to run it.
//!   Each claimed step resolves its function by name (in the run's app scope),
//!   builds an `ExecRequest`, and runs through the injected [`ExecutorClient`]
//!   — the same executor path every other trigger uses. Claimed steps run
//!   concurrently within the tick.
//!
//!   **B. Advance.** The step outcome is written and the DAG advanced in one
//!   token-gated transaction ([`workflow_repo::complete_step_and_advance`]):
//!   pending steps whose dependencies are now satisfied flip to `ready`, and
//!   the run's terminal status is recomputed. Fan-in falls out for free (a
//!   join step stays `pending` until its last dependency flips it). A stale
//!   worker (its lease reclaimed) matches zero rows and writes nothing.
//!
//! A second, slower cadence reclaims steps leased by a crashed worker
//! ([`workflow_repo::reclaim_stale_steps`]) — the durability safety net.
//!
//! Each claimed step is *prepared* ([`Prep`]) — the run context is built, its
//! `when` condition evaluated (false → `skipped`, M3), and its `input` template
//! resolved (M3) — then dispatched by target: a **function** runs through the
//! executor; a **`workflow`** starts a nested child run and parks the parent
//! step on it ([`workflow_repo::start_child_and_park`], M4), resumed by
//! [`workflow_repo::resume_finished_children`] once the child terminates. Nested
//! runs are bounded by [`WorkflowOrchestratorConfig::max_depth`].
//!
//! [`cron_scheduler`]: crate::cron_scheduler

use std::sync::Arc;
use std::time::Duration as StdDuration;

use chrono::{Duration as ChronoDuration, Utc};
use sqlx::PgPool;
use tokio::sync::OwnedSemaphorePermit;

use picloud_executor_core::{build_execution_log, ExecRequest, InvocationType};
use picloud_orchestrator_core::{ExecutionGate, ExecutorClient, ScriptIdentity};
use picloud_shared::workflow::{StepTarget, WorkflowBackoff, WorkflowDefinition, WorkflowStepDef};
use picloud_shared::{
    ExecutionId, ExecutionLogSink, ExecutionSource, RequestId, Script, WorkflowId,
};

use crate::dispatcher::compute_backoff;
use crate::repo::ScriptRepository;
use crate::trigger_config::BackoffShape;
use crate::workflow_expr;
use crate::workflow_repo::{
    claim_ready_step, complete_step_and_advance, get_workflow_by_name, load_run_step_outputs,
    reclaim_stale_steps, resume_finished_children, start_child_and_park, ClaimedStep, StepOutcome,
};
use crate::workflow_template::{self, RunContext};

/// The prepared action for a claimed step, after context build + `when` + input
/// resolution + target lookup. The orchestrator dispatches on this.
enum Prep {
    /// `when` evaluated false — skip the step (never executed).
    Skip,
    /// A resolution error, already turned into a `Failed` outcome.
    Fail(StepOutcome),
    /// A function step: run this script with this resolved body.
    Function {
        script: Box<Script>,
        body: serde_json::Value,
    },
    /// A nested sub-workflow step: start this child run with this input.
    StartChild {
        child_id: WorkflowId,
        child_def: WorkflowDefinition,
        input: serde_json::Value,
    },
}

/// Max steps claimed (and thus gate permits held) per orchestrator tick. Keeps
/// the workflow worker from monopolizing the gate it shares with HTTP + the
/// dispatcher; the tick cadence + reclaim pick up the rest.
const STEP_CLAIM_BATCH: usize = 16;

/// Tunables, all env-overridable (`PICLOUD_WORKFLOW_*`), matching the
/// parse-warn-fallback pattern the rest of the crate uses.
#[derive(Debug, Clone, Copy)]
pub struct WorkflowOrchestratorConfig {
    pub tick_interval_ms: u64,
    pub reclaim_interval_ms: u64,
    pub visibility_timeout_secs: u32,
    pub retry_jitter_pct: u32,
    pub step_timeout: StdDuration,
    /// Nesting ceiling for sub-workflows (M4). Read now so the knob is stable.
    pub max_depth: u32,
}

impl Default for WorkflowOrchestratorConfig {
    fn default() -> Self {
        Self {
            tick_interval_ms: 250,
            reclaim_interval_ms: 30_000,
            visibility_timeout_secs: 300,
            retry_jitter_pct: 20,
            step_timeout: StdDuration::from_secs(300),
            max_depth: 8,
        }
    }
}

impl WorkflowOrchestratorConfig {
    #[must_use]
    pub fn from_env() -> Self {
        let d = Self::default();
        Self {
            tick_interval_ms: env_u64("PICLOUD_WORKFLOW_TICK_INTERVAL_MS", d.tick_interval_ms),
            reclaim_interval_ms: env_u64(
                "PICLOUD_WORKFLOW_RECLAIM_INTERVAL_MS",
                d.reclaim_interval_ms,
            ),
            visibility_timeout_secs: env_u32(
                "PICLOUD_WORKFLOW_STEP_VISIBILITY_TIMEOUT_SECS",
                d.visibility_timeout_secs,
            ),
            retry_jitter_pct: env_u32("PICLOUD_WORKFLOW_RETRY_JITTER_PCT", d.retry_jitter_pct),
            step_timeout: StdDuration::from_secs(env_u64(
                "PICLOUD_WORKFLOW_STEP_TIMEOUT_SEC",
                d.step_timeout.as_secs(),
            )),
            max_depth: env_u32("PICLOUD_WORKFLOW_MAX_DEPTH", d.max_depth),
        }
    }
}

fn env_u64(key: &str, default: u64) -> u64 {
    match std::env::var(key) {
        Ok(v) => v.trim().parse::<u64>().map_or_else(
            |_| {
                tracing::warn!(%key, value = %v, "ignoring invalid value; want a positive integer");
                default
            },
            |n| if n > 0 { n } else { default },
        ),
        Err(_) => default,
    }
}

fn env_u32(key: &str, default: u32) -> u32 {
    u32::try_from(env_u64(key, u64::from(default))).unwrap_or(default)
}

/// The orchestrator's collaborators. All the durable state lives behind the
/// pool free-fns in [`workflow_repo`](crate::workflow_repo); execution goes
/// through the injected [`ExecutorClient`], never `executor-core` directly.
pub struct WorkflowOrchestrator {
    pub pool: PgPool,
    pub scripts: Arc<dyn ScriptRepository>,
    pub executor: Arc<dyn ExecutorClient>,
    pub gate: Arc<ExecutionGate>,
    pub log_sink: Arc<dyn ExecutionLogSink>,
    pub config: WorkflowOrchestratorConfig,
}

impl WorkflowOrchestrator {
    /// Spawn the advance loop + the reclaim task. Both run for the process
    /// lifetime; returned handles are dropped on purpose (as with the
    /// dispatcher + cron scheduler).
    pub fn spawn(self) {
        // Reclaim task — independent, slower cadence so it doesn't contend
        // with the per-tick advance loop.
        let reclaim_pool = self.pool.clone();
        let reclaim_interval = StdDuration::from_millis(self.config.reclaim_interval_ms.max(1_000));
        let visibility = self.config.visibility_timeout_secs;
        tokio::spawn(async move {
            let mut ticker = tokio::time::interval(reclaim_interval);
            ticker.tick().await;
            loop {
                ticker.tick().await;
                match reclaim_stale_steps(&reclaim_pool, visibility).await {
                    Ok(0) => {}
                    Ok(n) => {
                        tracing::info!(reclaimed = n, "workflow step visibility-timeout reclaim");
                    }
                    Err(e) => tracing::warn!(?e, "workflow reclaim task errored"),
                }
            }
        });

        // Advance loop.
        let interval = StdDuration::from_millis(self.config.tick_interval_ms.max(50));
        tokio::spawn(async move {
            let mut ticker = tokio::time::interval(interval);
            ticker.tick().await;
            loop {
                ticker.tick().await;
                self.tick_once().await;
            }
        });
    }

    /// One advance tick: claim a permit-bounded batch of ready steps, then run
    /// them (execute + advance) concurrently. Public so integration tests can
    /// drive the loop deterministically (one wave per call).
    pub async fn tick_once(&self) {
        use futures::stream::{self, StreamExt};

        // M4: resolve any parent steps whose child sub-workflow has finished —
        // first, so a just-completed child promotes the parent's dependents to
        // `ready` in time for this tick's claim scan below.
        match resume_finished_children(&self.pool).await {
            Ok(0) => {}
            Ok(n) => tracing::debug!(resolved = n, "workflow parent steps resumed"),
            Err(e) => tracing::warn!(?e, "workflow child-resume errored"),
        }

        let mut batch: Vec<(ClaimedStep, OwnedSemaphorePermit)> = Vec::new();
        for _ in 0..STEP_CLAIM_BATCH {
            // Acquire the gate slot BEFORE claiming so a claimed step always
            // has somewhere to run; if the gate is saturated, stop this tick.
            let Ok(permit) = self.gate.try_acquire() else {
                break;
            };
            match claim_ready_step(&self.pool).await {
                Ok(Some(claimed)) => batch.push((claimed, permit)),
                Ok(None) => {
                    drop(permit);
                    break;
                }
                Err(e) => {
                    drop(permit);
                    tracing::warn!(?e, "workflow claim errored");
                    break;
                }
            }
        }
        if batch.is_empty() {
            return;
        }
        // Each entry already holds a permit, so run them all concurrently.
        stream::iter(batch)
            .for_each_concurrent(None, |(claimed, permit)| async move {
                self.run_step(claimed, permit).await;
            })
            .await;
    }

    /// Process one claimed step: prepare it (context → `when` → input →
    /// target), then dispatch — run a function, start a sub-workflow, skip, or
    /// fail.
    async fn run_step(&self, claimed: ClaimedStep, permit: OwnedSemaphorePermit) {
        match self.prepare(&claimed).await {
            Prep::Skip => {
                drop(permit);
                self.complete(&claimed, StepOutcome::Skipped).await;
            }
            Prep::Fail(outcome) => {
                drop(permit);
                self.complete(&claimed, outcome).await;
            }
            Prep::Function { script, body } => {
                let outcome = self.execute_function(&claimed, &script, body).await;
                // Release the gate slot before the (DB-only, fast) advance.
                drop(permit);
                self.complete(&claimed, outcome).await;
            }
            Prep::StartChild {
                child_id,
                child_def,
                input,
            } => {
                // A nested start runs no script — release the gate slot; the
                // child's own steps compete for it when they run.
                drop(permit);
                if let Err(e) =
                    start_child_and_park(&self.pool, &claimed, child_id, &child_def, input).await
                {
                    tracing::error!(
                        ?e,
                        run_id = %claimed.run_id,
                        step = %claimed.step_name,
                        "workflow nested-start write failed"
                    );
                }
            }
        }
    }

    /// Write a step outcome + advance the run, logging any DB error.
    async fn complete(&self, claimed: &ClaimedStep, outcome: StepOutcome) {
        if let Err(e) = complete_step_and_advance(&self.pool, claimed, outcome).await {
            tracing::error!(
                ?e,
                run_id = %claimed.run_id,
                step = %claimed.step_name,
                "workflow step advance write failed"
            );
        }
    }

    /// Prepare a claimed step: build the run context, evaluate `when`, resolve
    /// the input template, and resolve the target. Pure of side effects beyond
    /// the reads it needs; the caller dispatches on the returned [`Prep`].
    #[allow(clippy::too_many_lines)]
    async fn prepare(&self, claimed: &ClaimedStep) -> Prep {
        let Some(step) = claimed.step_def() else {
            return Prep::Fail(self.fail(
                None,
                claimed,
                "step not found in workflow definition".into(),
            ));
        };

        // M3: build the run context (run input + prior succeeded outputs) once;
        // it backs both the `when` condition and the input template.
        let outputs = match load_run_step_outputs(&self.pool, claimed.run_id).await {
            Ok(o) => o,
            Err(e) => {
                return Prep::Fail(self.fail(
                    Some(step),
                    claimed,
                    format!("loading step context: {e}"),
                ));
            }
        };
        let ctx = RunContext {
            input: claimed.run_input.clone(),
            steps: outputs,
        };

        // M3: a false `when` skips the step — never executed, satisfied-but-empty.
        if let Some(cond) = &step.when {
            match workflow_expr::eval(cond, &ctx) {
                Ok(true) => {}
                Ok(false) => return Prep::Skip,
                Err(e) => {
                    return Prep::Fail(self.fail(
                        Some(step),
                        claimed,
                        format!("evaluating `when`: {e}"),
                    ));
                }
            }
        }

        // M3: resolve `{{ … }}` references in the step input. Null passes the
        // run input through; a missing reference is a hard failure.
        let body = if step.input.is_null() {
            claimed.run_input.clone()
        } else {
            match workflow_template::resolve(&step.input, &ctx) {
                Ok(v) => v,
                Err(e) => {
                    return Prep::Fail(self.fail(
                        Some(step),
                        claimed,
                        format!("resolving input: {e}"),
                    ));
                }
            }
        };

        match step.target() {
            Some(StepTarget::Function(fn_name)) => {
                // Resolve the function by name in the RUN's app scope (an
                // inherited group script is reachable, nearest-owner-wins) —
                // never off a script-passed arg. cx isolation boundary held.
                match self
                    .scripts
                    .get_by_name_inherited(claimed.app_id, fn_name)
                    .await
                {
                    Ok(Some(s)) if s.enabled => Prep::Function {
                        script: Box::new(s),
                        body,
                    },
                    Ok(Some(_)) => Prep::Fail(self.fail(
                        Some(step),
                        claimed,
                        format!("function {fn_name:?} is disabled"),
                    )),
                    Ok(None) => Prep::Fail(self.fail(
                        Some(step),
                        claimed,
                        format!("function {fn_name:?} not found"),
                    )),
                    Err(e) => Prep::Fail(self.fail(
                        Some(step),
                        claimed,
                        format!("resolving {fn_name:?}: {e}"),
                    )),
                }
            }
            Some(StepTarget::Workflow(wf_name)) => {
                // M4: nesting depth ceiling — the child would run at depth + 1.
                let ceiling = i32::try_from(self.config.max_depth).unwrap_or(i32::MAX);
                if claimed.workflow_depth + 1 > ceiling {
                    return Prep::Fail(self.fail(
                        Some(step),
                        claimed,
                        format!(
                            "max workflow nesting depth ({}) exceeded",
                            self.config.max_depth
                        ),
                    ));
                }
                match get_workflow_by_name(&self.pool, claimed.app_id, wf_name).await {
                    Ok(Some(w)) if w.enabled => Prep::StartChild {
                        child_id: w.id,
                        child_def: w.definition,
                        input: body,
                    },
                    Ok(Some(_)) => Prep::Fail(self.fail(
                        Some(step),
                        claimed,
                        format!("workflow {wf_name:?} is disabled"),
                    )),
                    Ok(None) => Prep::Fail(self.fail(
                        Some(step),
                        claimed,
                        format!("workflow {wf_name:?} not found"),
                    )),
                    Err(e) => Prep::Fail(self.fail(
                        Some(step),
                        claimed,
                        format!("resolving workflow {wf_name:?}: {e}"),
                    )),
                }
            }
            None => Prep::Fail(self.fail(
                Some(step),
                claimed,
                "step declares neither `function` nor `workflow`".into(),
            )),
        }
    }

    /// Run a resolved function step through the executor, returning its outcome
    /// (a failure carries the step's retry-policy backoff). Logs the run under
    /// `ExecutionSource::Workflow` so `pic logs` surfaces it.
    async fn execute_function(
        &self,
        claimed: &ClaimedStep,
        script: &Script,
        body: serde_json::Value,
    ) -> StepOutcome {
        let request_id = RequestId::new();
        let req = ExecRequest {
            execution_id: ExecutionId::new(),
            request_id,
            script_id: script.id,
            script_name: script.name.clone(),
            invocation_type: InvocationType::Function,
            path: format!("/workflow/{}", claimed.step_name),
            method: String::new(),
            headers: std::collections::BTreeMap::new(),
            body: body.clone(),
            params: std::collections::BTreeMap::new(),
            query: std::collections::BTreeMap::new(),
            rest: String::new(),
            sandbox_overrides: script.sandbox,
            app_id: claimed.app_id,
            script_owner: script.owner(),
            // Like invoke_async / HTTP outbox rows: steps run with no principal
            // (the run's origin is forensic, not re-authenticated).
            principal: None,
            trigger_depth: 0,
            root_execution_id: ExecutionId::from(claimed.root_execution_id),
            is_dead_letter_handler: false,
            event: None,
        };
        let identity = ScriptIdentity {
            script_id: script.id,
            updated_at: script.updated_at,
        };

        let started = Utc::now();
        let outcome = self
            .executor
            .execute_with_identity(identity, &script.source, req, self.config.step_timeout)
            .await;
        let finished = Utc::now();

        let log = build_execution_log(
            claimed.app_id,
            script.id,
            request_id,
            format!("/workflow/{}", claimed.step_name),
            std::collections::BTreeMap::new(),
            body,
            ExecutionSource::Workflow,
            &outcome,
            started,
            finished,
        );
        if let Err(e) = self.log_sink.record(log).await {
            tracing::warn!(?e, step = %claimed.step_name, "workflow step log write failed");
        }

        match outcome {
            Ok(resp) => StepOutcome::Succeeded(resp.body),
            Err(err) => self.fail(claimed.step_def(), claimed, err.to_string()),
        }
    }

    /// Build a `Failed` outcome, computing the retry backoff from the step's
    /// own retry policy (zero if it has none — the repo then treats the first
    /// failure as terminal).
    fn fail(
        &self,
        step: Option<&WorkflowStepDef>,
        claimed: &ClaimedStep,
        error: String,
    ) -> StepOutcome {
        let retry_delay = step
            .and_then(|s| s.retry.as_ref())
            .map_or(ChronoDuration::zero(), |r| {
                let ms = compute_backoff(
                    u32::try_from(claimed.attempt.max(1)).unwrap_or(1),
                    map_backoff(r.backoff),
                    r.base_ms,
                    self.config.retry_jitter_pct,
                );
                ChronoDuration::milliseconds(i64::from(ms))
            });
        StepOutcome::Failed { error, retry_delay }
    }
}

/// Map the self-contained workflow backoff DTO onto the shared trigger backoff.
fn map_backoff(b: WorkflowBackoff) -> BackoffShape {
    match b {
        WorkflowBackoff::Constant => BackoffShape::Constant,
        WorkflowBackoff::Linear => BackoffShape::Linear,
        WorkflowBackoff::Exponential => BackoffShape::Exponential,
    }
}

/// Consume the orchestrator and spawn its background tasks (mirrors
/// [`spawn_cron_scheduler`](crate::spawn_cron_scheduler)).
pub fn spawn_workflow_orchestrator(orchestrator: WorkflowOrchestrator) {
    orchestrator.spawn();
}
