//! Name resolution and runAfter graph construction.
//!
//! The resolver is the pass between parser and emitter. It assigns each
//! statement a Power Automate action key (uniqued by suffix when needed),
//! links actions by source order (so the emitter can set `runAfter`),
//! tracks each binding in an environment, and lowers each AST statement
//! into a concrete `ActionKind` the emitter can render directly.
//!
//! Expression resolution happens here: every `Expr::Ref(name)` emitted by
//! the parser is rewritten to either `Expr::VarRef` (pointing at a pax
//! variable) or `Expr::ComposeRef` (pointing at a `let` binding's Compose
//! action key), so the emitter never sees an unresolved reference.

use crate::ast::{
    AssignOp, DebugArg, Expr, HandlerStatus, Literal, Program, Stmt, TerminateStatus, Type,
};
use crate::lexer::Span;
use crate::pa::names::{key_prefix, status};
use serde_json::Value as JsonValue;
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone)]
pub struct ResolvedProgram {
    pub trigger: ResolvedTrigger,
    /// Top-level connection references read from `pa/connectionReferences.json`
    /// when present. Emitted alongside the flow definition; consumed by the
    /// packager when wrapping the flow envelope. None when no file is present.
    pub connection_references: Option<JsonValue>,
    pub actions: Vec<ResolvedAction>,
}

/// The flow's trigger, determined at resolve time by scanning the source
/// directory's `pa/` folder for a `*.trigger.json` file. With no trigger
/// file present, paxc generates a default manual ("Button") trigger so a
/// freshly-written pax program is always runnable without any boilerplate.
#[derive(Debug, Clone)]
pub enum ResolvedTrigger {
    /// No trigger file was found (or no source dir was provided to the
    /// resolver). The emitter generates a `manual` Button trigger.
    DefaultManual,
    /// A `pa/<name>.trigger.json` file was found. `name` is the filename
    /// without the `.trigger.json` suffix and becomes the PA trigger key.
    /// `body_json` is the parsed file contents -- emitted verbatim.
    FromFile { name: String, body_json: JsonValue },
}

#[derive(Debug, Clone)]
pub struct ResolvedAction {
    pub name: String,
    pub run_after: Vec<RunAfterEntry>,
    pub kind: ActionKind,
    /// Span of the source statement. paxr uses this to attribute runtime
    /// errors back to the originating statement in diagnostic output.
    pub span: Span,
}

/// One predecessor in PA's `runAfter` map: an action name plus the statuses
/// under which it triggers this action. Most entries are built via
/// [`RunAfterEntry::succeeded`] (the source-order sibling chain); error-path
/// handlers (`on failed foo { ... }`) use other statuses.
#[derive(Debug, Clone)]
pub struct RunAfterEntry {
    pub action_name: String,
    /// PA-capitalized statuses: `Succeeded`, `Failed`, `Skipped`, `TimedOut`.
    pub statuses: Vec<String>,
}

impl RunAfterEntry {
    /// The default sibling-chain edge: "this action runs after <name> has
    /// succeeded." Used by the source-order runAfter chain.
    pub fn succeeded(action_name: String) -> Self {
        Self {
            action_name,
            statuses: vec![status::SUCCEEDED.to_string()],
        }
    }
}

#[derive(Debug, Clone)]
pub enum ActionKind {
    InitializeVariable {
        var: String,
        ty: Type,
        /// `None` means the source omitted the initializer (`var x: T`);
        /// emitter skips the `value` key in PA's variables[0], and paxr
        /// initializes the runtime variable to its type's zero value.
        value: Option<Expr>,
    },
    SetVariable {
        var: String,
        value: Expr,
    },
    IncrementVariable {
        var: String,
        value: Expr,
    },
    DecrementVariable {
        var: String,
        value: Expr,
    },
    AppendToStringVariable {
        var: String,
        value: Expr,
    },
    AppendToArrayVariable {
        var: String,
        value: Expr,
    },
    Compose {
        /// The original user-facing `let` binding name. paxr uses this for
        /// the state dump; the emitter keys actions by `ResolvedAction.name`,
        /// which may differ (e.g. `Compose_remaining_1` when suffixed).
        name: String,
        value: Expr,
    },
    /// Opaque PA action whose body was loaded from `pa/<name>.json` next
    /// to the source. The emitter drops the JSON verbatim under the
    /// action's key. The resolver reads the file at compile time so any
    /// missing-file or malformed-JSON failure surfaces with a span.
    Pa {
        body_json: JsonValue,
    },
    Condition {
        condition: Expr,
        /// Source span of the condition, threaded through from the parser
        /// so paxr can render `condition? (source) = true/false` in verbose
        /// traces. The emitter ignores this field.
        condition_span: Span,
        true_branch: Vec<ResolvedAction>,
        false_branch: Vec<ResolvedAction>,
    },
    Foreach {
        collection: Expr,
        /// User-facing iterator name (e.g. `task` from `foreach task in ...`),
        /// used by paxr verbose traces. The emitter keys by `action.name`.
        iter_name: String,
        body: Vec<ResolvedAction>,
    },
    /// `debug(args)` diagnostic. paxc skips these at emit time and counts
    /// them for the end-of-compile note. paxr evaluates them and prints.
    Debug {
        args: Vec<DebugArg>,
        span: Span,
    },
    /// `terminate <status> [message] [code <code-expr>]`. Compiles to a PA
    /// Terminate action; paxr halts execution on reaching one. Both message
    /// and code are only present when status is Failed (parser-enforced).
    Terminate {
        status: TerminateStatus,
        message: Option<Expr>,
        code: Option<Expr>,
    },
    /// `scope [name] { body }` -- a PA Scope action. Pure container; the
    /// interpreter walks the body like any other block. Only the name form
    /// varies the action key.
    Scope {
        body: Vec<ResolvedAction>,
    },
    /// `until <condition> [max N] [timeout "..."] { body }` -- PA's Until
    /// loop. The condition is the exit condition; body runs at least once.
    /// `limit_count` / `limit_timeout` carry user overrides; when either is
    /// `None` the emitter falls back to PA's defaults (60 iterations,
    /// PT1H timeout). paxr uses `limit_count` when set to cap its local
    /// simulation, so iteration-cap notices match what PA would do.
    Until {
        condition: Expr,
        /// Source span of the condition expression, mirrors Condition's span.
        condition_span: Span,
        /// Validated iteration cap from `max N`; None means "PA default".
        limit_count: Option<u32>,
        /// ISO 8601 duration string from `timeout "..."`; None means "PA
        /// default". paxc does not validate the string; PA validates on
        /// import.
        limit_timeout: Option<String>,
        body: Vec<ResolvedAction>,
    },
    /// `on <status> [or <status>]* <target> { body }` -- handler attached to
    /// a named scope (or, in the future, any named action). Compiles to a PA
    /// Scope action whose `runAfter` points at the target with one or more
    /// statuses. The handler does not participate in the source-order sibling
    /// chain: the resolver skips updating `prev_name` when emitting it, so
    /// the next real statement chains back to whatever came before.
    OnHandler {
        /// One or more handler statuses, in source order, guaranteed unique
        /// by the resolver's duplicate check.
        statuses: Vec<HandlerStatus>,
        /// PA action name of the target (e.g. `Scope_try_work`), already
        /// resolved from the source-level label (`try_work`).
        target_action_name: String,
        body: Vec<ResolvedAction>,
    },
    /// `switch subject { case L { ... } ... default { ... } }`. Each case
    /// has a literal value and a resolved body; `default` is `None` when the
    /// source omitted the default arm.
    Switch {
        subject: Expr,
        /// Source span of the subject expression, threaded through for paxr's
        /// verbose trace (parallel to Condition's `condition_span`).
        subject_span: Span,
        cases: Vec<ResolvedSwitchCase>,
        default: Option<Vec<ResolvedAction>>,
    },
}

#[derive(Debug, Clone)]
pub struct ResolvedSwitchCase {
    /// PA action key for this case (e.g. `Case`, `Case_1`). Lives inside the
    /// parent Switch's `cases` map; name counts are shared with the top-level
    /// counter for simplicity, so these are globally unique too.
    pub action_name: String,
    pub value: Literal,
    pub body: Vec<ResolvedAction>,
}

#[derive(Debug, Clone)]
enum Binding {
    Var { ty: Type },
    Let { action_name: String },
    Iterator { action_name: String },
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolveError {
    DuplicateVariable {
        name: String,
        span: Span,
    },
    UndefinedVariable {
        name: String,
        span: Span,
    },
    InvalidOperation {
        op: AssignOp,
        name: String,
        ty: Type,
        span: Span,
    },
    CannotAssignToImmutable {
        name: String,
        span: Span,
    },
    /// `var` declarations must be at the top level of the flow. PA's
    /// `InitializeVariable` action is only valid at the workflow scope,
    /// so nesting one inside a Condition or Apply_to_each produces an
    /// invalid definition.
    NestedVarDeclaration {
        name: String,
        span: Span,
    },
    /// `on <status> <target> { ... }` named a target that no named scope
    /// or raw block resolves to. `scope <name> { ... }` and `raw <name>
    /// { ... }` both register as handler targets; future versions may
    /// allow additional named actions.
    UnknownHandlerTarget {
        name: String,
        span: Span,
    },
    /// Multi-status handler `on a or b or ... <target>` listed the same
    /// status twice. Redundant but usually a typo, so reject rather than
    /// silently dedup.
    DuplicateHandlerStatus {
        status: HandlerStatus,
        span: Span,
    },
    /// `until ... max N` with an N outside the valid range for PA's Until
    /// limit count (strictly positive, fits in u32). Raised at resolve time
    /// because the parser accepts any int literal.
    InvalidUntilLimit {
        value: i64,
        span: Span,
    },
    /// A `scope <name>` or `pa <name>` block declared a name that was
    /// already registered as a handler target earlier in the program. The
    /// previous declaration would silently be shadowed in the registry,
    /// which would misroute any later `on <status> <name>` handler.
    /// Both kinds share one namespace.
    DuplicateHandlerTarget {
        name: String,
        span: Span,
    },
    /// `pa <name>` was used but the resolver wasn't given a source
    /// directory to look up `pa/<name>.json` against. Caller must invoke
    /// `resolve` with `Some(source_dir)` for any program containing pa
    /// references.
    PaSourceDirRequired {
        name: String,
        span: Span,
    },
    /// `pa <name>` referenced a JSON file that doesn't exist on disk.
    PaFileNotFound {
        name: String,
        path: PathBuf,
        span: Span,
    },
    /// `pa <name>` found the file but its contents are not valid JSON.
    PaFileInvalidJson {
        name: String,
        path: PathBuf,
        message: String,
        span: Span,
    },
    /// The source directory's `pa/` folder couldn't be read while scanning
    /// for trigger or connection-reference files.
    PaDirReadError {
        path: PathBuf,
        message: String,
    },
    /// More than one `*.trigger.json` file was found under `pa/`. PA flows
    /// can only have one trigger, so paxc rejects this rather than picking
    /// arbitrarily.
    MultipleTriggerFiles {
        names: Vec<String>,
    },
}

impl ResolveError {
    pub fn span(&self) -> Span {
        match self {
            ResolveError::DuplicateVariable { span, .. }
            | ResolveError::UndefinedVariable { span, .. }
            | ResolveError::InvalidOperation { span, .. }
            | ResolveError::CannotAssignToImmutable { span, .. }
            | ResolveError::NestedVarDeclaration { span, .. }
            | ResolveError::UnknownHandlerTarget { span, .. }
            | ResolveError::DuplicateHandlerStatus { span, .. }
            | ResolveError::InvalidUntilLimit { span, .. }
            | ResolveError::DuplicateHandlerTarget { span, .. }
            | ResolveError::PaSourceDirRequired { span, .. }
            | ResolveError::PaFileNotFound { span, .. }
            | ResolveError::PaFileInvalidJson { span, .. } => *span,
            ResolveError::PaDirReadError { .. } | ResolveError::MultipleTriggerFiles { .. } => {
                (0..0).into()
            }
        }
    }

    /// Short label to attach to the offending span in ariadne output.
    pub fn label(&self) -> &'static str {
        match self {
            ResolveError::DuplicateVariable { .. } => "already declared",
            ResolveError::UndefinedVariable { .. } => "not defined",
            ResolveError::InvalidOperation { .. } => "invalid operation",
            ResolveError::CannotAssignToImmutable { .. } => "immutable binding",
            ResolveError::NestedVarDeclaration { .. } => "must be top-level",
            ResolveError::UnknownHandlerTarget { .. } => "not a named scope or pa action",
            ResolveError::DuplicateHandlerStatus { .. } => "duplicate status",
            ResolveError::InvalidUntilLimit { .. } => "invalid until limit",
            ResolveError::DuplicateHandlerTarget { .. } => "name already used",
            ResolveError::PaSourceDirRequired { .. } => "no source directory",
            ResolveError::PaFileNotFound { .. } => "file not found",
            ResolveError::PaFileInvalidJson { .. } => "invalid JSON",
            ResolveError::PaDirReadError { .. } => "pa directory unreadable",
            ResolveError::MultipleTriggerFiles { .. } => "multiple triggers",
        }
    }
}

impl fmt::Display for ResolveError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ResolveError::DuplicateVariable { name, .. } => {
                write!(f, "`{name}` is already declared")
            }
            ResolveError::UndefinedVariable { name, .. } => {
                write!(f, "`{name}` is not defined")
            }
            ResolveError::InvalidOperation { op, name, ty, .. } => {
                let op_str = match op {
                    AssignOp::Set => "=",
                    AssignOp::Add => "+=",
                    AssignOp::Subtract => "-=",
                    AssignOp::Concat => "&=",
                };
                let ty_str = type_name(ty);
                write!(
                    f,
                    "cannot apply `{op_str}` to variable `{name}` of type `{ty_str}`"
                )
            }
            ResolveError::CannotAssignToImmutable { name, .. } => {
                write!(f, "cannot assign to `{name}`: `let` bindings are immutable")
            }
            ResolveError::NestedVarDeclaration { name, .. } => {
                write!(
                    f,
                    "`var {name}` must be declared at the top level of the flow"
                )
            }
            ResolveError::UnknownHandlerTarget { name, .. } => {
                write!(
                    f,
                    "`{name}` is not a named scope or pa action; `on` handlers attach to `scope <name> {{ ... }}` or `pa <name>`"
                )
            }
            ResolveError::DuplicateHandlerStatus { status, .. } => {
                write!(
                    f,
                    "status `{}` appears more than once in this handler",
                    status.as_label()
                )
            }
            ResolveError::InvalidUntilLimit { value, .. } => {
                write!(
                    f,
                    "`max {value}` is not a valid until limit; count must be a positive integer that fits in 32 bits"
                )
            }
            ResolveError::DuplicateHandlerTarget { name, .. } => {
                write!(
                    f,
                    "`{name}` is already used as a scope or pa action name earlier in the program; `on` handlers attach by name, so each handler target must be unique"
                )
            }
            ResolveError::PaSourceDirRequired { name, .. } => {
                write!(
                    f,
                    "`pa {name}` requires a source directory but none was provided to the resolver"
                )
            }
            ResolveError::PaFileNotFound { name, path, .. } => {
                write!(
                    f,
                    "`pa {name}` references `{}` but that file was not found",
                    path.display()
                )
            }
            ResolveError::PaFileInvalidJson {
                name,
                path,
                message,
                ..
            } => {
                write!(
                    f,
                    "`pa {name}`: `{}` is not valid JSON: {message}",
                    path.display()
                )
            }
            ResolveError::PaDirReadError { path, message } => {
                write!(f, "could not read `{}`: {message}", path.display())
            }
            ResolveError::MultipleTriggerFiles { names } => {
                write!(
                    f,
                    "found multiple `*.trigger.json` files under `pa/` ({}); a flow can only have one trigger",
                    names.join(", ")
                )
            }
        }
    }
}

impl std::error::Error for ResolveError {}

fn type_name(ty: &Type) -> &'static str {
    match ty {
        Type::Int => "int",
        Type::Float => "float",
        Type::String => "string",
        Type::Bool => "bool",
        Type::Array => "array",
        Type::Object => "object",
    }
}

/// Resolve a parsed program into a flow-shaped action sequence. `source_dir`
/// is the directory of the input `.pax` file -- it's used to resolve
/// `pa <Name>` references against `<source_dir>/pa/<Name>.json`. Tests that
/// build `Program` ASTs by hand without any pa references can pass `None`;
/// any pa reference in such a program produces `PaSourceDirRequired`.
pub fn resolve(
    program: &Program,
    source_dir: Option<&Path>,
) -> Result<ResolvedProgram, ResolveError> {
    let mut env: HashMap<String, Binding> = HashMap::new();
    let mut name_counts: HashMap<String, u32> = HashMap::new();
    // Source name -> PA action name for every user-labeled handler target
    // (named scopes and pa actions). Populated as we resolve each
    // `scope <name>` and `pa <name>`; consumed when an `on` handler
    // references a target by name. Variable name is historical -- the
    // registry covers both scopes and pa actions now. This map ROLLS BACK
    // at block exit, so handler resolution only sees targets visible at
    // the handler's declaration point -- a scope inside an if-branch is
    // not a visible handler target once the branch ends.
    let mut named_scopes: HashMap<String, String> = HashMap::new();
    // Every source-level handler target name ever declared, regardless of
    // nesting. Does NOT roll back at block exit. Used solely for duplicate
    // detection so that `scope foo` inside an if-branch followed by
    // `scope foo` at sibling or outer level is rejected. Both kinds of
    // block (scope / pa) share this namespace; PA would otherwise end up
    // with two identically-labeled actions in the compiled flow.
    let mut declared_targets: HashSet<String> = HashSet::new();
    // Optional pax-safe → PA-original name override map, sourced from
    // `pa/flow.json.actionNameMap`. When the decoder normalized a PA action
    // key (e.g. `Send_an_email_(V2)` → `Send_an_email_V2`) to fit pax's
    // identifier rules, this lets the encode side swap back so the round
    // trip preserves the original key in the emitted flow JSON.
    let pa_overrides = source_dir.and_then(read_pa_action_overrides);
    let actions = resolve_statements(
        &program.statements,
        &mut env,
        &mut name_counts,
        &mut named_scopes,
        &mut declared_targets,
        source_dir,
        pa_overrides.as_ref(),
        true,
    )?;
    let trigger = match source_dir {
        Some(dir) => discover_trigger(dir)?,
        None => ResolvedTrigger::DefaultManual,
    };
    let connection_references = match source_dir {
        Some(dir) => read_connection_references(dir)?,
        None => None,
    };
    Ok(ResolvedProgram {
        trigger,
        connection_references,
        actions,
    })
}

/// Scan `<source_dir>/pa/` for a single `*.trigger.json` file. Zero files
/// means default manual. Two or more is a resolver error -- a flow can
/// only have one trigger.
fn discover_trigger(source_dir: &Path) -> Result<ResolvedTrigger, ResolveError> {
    let pa_dir = source_dir.join("pa");
    if !pa_dir.is_dir() {
        return Ok(ResolvedTrigger::DefaultManual);
    }
    let entries = std::fs::read_dir(&pa_dir).map_err(|err| ResolveError::PaDirReadError {
        path: pa_dir.clone(),
        message: err.to_string(),
    })?;
    let mut found: Vec<(String, PathBuf)> = Vec::new();
    for entry in entries.flatten() {
        let path = entry.path();
        let Some(file_name) = path.file_name().and_then(|s| s.to_str()) else {
            continue;
        };
        if let Some(stripped) = file_name.strip_suffix(".trigger.json") {
            found.push((stripped.to_string(), path));
        }
    }
    match found.len() {
        0 => Ok(ResolvedTrigger::DefaultManual),
        1 => {
            let (name, path) = found.into_iter().next().unwrap();
            let bytes = std::fs::read(&path).map_err(|_| ResolveError::PaFileNotFound {
                name: name.clone(),
                path: path.clone(),
                span: (0..0).into(),
            })?;
            let body_json: JsonValue =
                serde_json::from_slice(&bytes).map_err(|err| ResolveError::PaFileInvalidJson {
                    name: name.clone(),
                    path: path.clone(),
                    message: err.to_string(),
                    span: (0..0).into(),
                })?;
            Ok(ResolvedTrigger::FromFile { name, body_json })
        }
        _ => {
            let mut names: Vec<String> = found.into_iter().map(|(n, _)| n).collect();
            names.sort();
            Err(ResolveError::MultipleTriggerFiles { names })
        }
    }
}

/// Read `<source_dir>/pa/flow.json` for the `actionNameMap` (PA-original →
/// pax-safe identifier, populated by the decoder when ingesting an export
/// whose PA action keys had characters outside `[A-Za-z0-9_]`). Returns the
/// INVERSE (pax-safe → PA-original) so the resolver can swap a `pa <Name>`
/// statement back to the PA action key on emit. Silent failures (file
/// missing, malformed, no actionNameMap field) return None — name fidelity
/// is best-effort; the round-trip works even without it.
fn read_pa_action_overrides(source_dir: &Path) -> Option<HashMap<String, String>> {
    let path = source_dir.join("pa").join("flow.json");
    let bytes = std::fs::read(&path).ok()?;
    let value: JsonValue = serde_json::from_slice(&bytes).ok()?;
    let map = value.get("actionNameMap")?.as_object()?;
    let mut inverse: HashMap<String, String> = HashMap::with_capacity(map.len());
    for (pa_original, pax_safe) in map {
        if let Some(safe) = pax_safe.as_str() {
            inverse.insert(safe.to_string(), pa_original.clone());
        }
    }
    if inverse.is_empty() {
        None
    } else {
        Some(inverse)
    }
}

/// Read `<source_dir>/pa/connectionReferences.json` if present. None when
/// the file isn't there; an error if the file exists but doesn't parse.
fn read_connection_references(source_dir: &Path) -> Result<Option<JsonValue>, ResolveError> {
    let path = source_dir.join("pa").join("connectionReferences.json");
    if !path.exists() {
        return Ok(None);
    }
    let bytes = std::fs::read(&path).map_err(|err| ResolveError::PaDirReadError {
        path: path.clone(),
        message: err.to_string(),
    })?;
    let value: JsonValue =
        serde_json::from_slice(&bytes).map_err(|err| ResolveError::PaFileInvalidJson {
            name: "connectionReferences".to_string(),
            path: path.clone(),
            message: err.to_string(),
            span: (0..0).into(),
        })?;
    Ok(Some(value))
}

/// `top_level` controls whether `var` declarations are permitted. PA requires
/// `InitializeVariable` actions at workflow scope, so nested `var` decls must
/// be rejected at compile time. `let` / assign / pa are fine at any depth.
///
/// `pa_overrides`, when present, maps a pax-safe identifier (the source
/// form of a `pa <Name>` statement) to the original PA action key it
/// should re-emit as. Used by the round-trip path; otherwise None.
#[allow(clippy::too_many_arguments)]
fn resolve_statements(
    statements: &[Stmt],
    env: &mut HashMap<String, Binding>,
    name_counts: &mut HashMap<String, u32>,
    named_scopes: &mut HashMap<String, String>,
    declared_targets: &mut HashSet<String>,
    source_dir: Option<&Path>,
    pa_overrides: Option<&HashMap<String, String>>,
    top_level: bool,
) -> Result<Vec<ResolvedAction>, ResolveError> {
    let mut actions: Vec<ResolvedAction> = Vec::with_capacity(statements.len());
    let mut prev_name: Option<String> = None;

    for stmt in statements {
        let (action_name, kind, stmt_span) = match stmt {
            Stmt::VarDecl {
                name,
                name_span,
                ty,
                value,
            } => {
                if !top_level {
                    return Err(ResolveError::NestedVarDeclaration {
                        name: name.clone(),
                        span: *name_span,
                    });
                }
                if env.contains_key(name) {
                    return Err(ResolveError::DuplicateVariable {
                        name: name.clone(),
                        span: *name_span,
                    });
                }
                let value = match value {
                    Some(e) => Some(resolve_expr(e, env)?),
                    None => None,
                };
                env.insert(name.clone(), Binding::Var { ty: ty.clone() });
                let action_name =
                    unique_name(&format!("{}_{name}", key_prefix::INITIALIZE), name_counts);
                let kind = ActionKind::InitializeVariable {
                    var: name.clone(),
                    ty: ty.clone(),
                    value,
                };
                (action_name, kind, *name_span)
            }
            Stmt::Let {
                name,
                name_span,
                value,
            } => {
                if env.contains_key(name) {
                    return Err(ResolveError::DuplicateVariable {
                        name: name.clone(),
                        span: *name_span,
                    });
                }
                let value = resolve_expr(value, env)?;
                let action_name =
                    unique_name(&format!("{}_{name}", key_prefix::COMPOSE), name_counts);
                env.insert(
                    name.clone(),
                    Binding::Let {
                        action_name: action_name.clone(),
                    },
                );
                (
                    action_name,
                    ActionKind::Compose {
                        name: name.clone(),
                        value,
                    },
                    *name_span,
                )
            }
            Stmt::Assign {
                name,
                name_span,
                op,
                value,
            } => {
                let value = resolve_expr(value, env)?;
                match env.get(name) {
                    Some(Binding::Var { ty }) => {
                        let ty = ty.clone();
                        let (base, kind) = lower_assign(name, *name_span, *op, &ty, value)?;
                        (unique_name(&base, name_counts), kind, *name_span)
                    }
                    Some(Binding::Let { .. }) | Some(Binding::Iterator { .. }) => {
                        return Err(ResolveError::CannotAssignToImmutable {
                            name: name.clone(),
                            span: *name_span,
                        });
                    }
                    None => {
                        return Err(ResolveError::UndefinedVariable {
                            name: name.clone(),
                            span: *name_span,
                        });
                    }
                }
            }
            Stmt::Pa {
                name,
                name_span: _,
                span,
            } => {
                // Enforce uniqueness across every handler target ever
                // declared in the program. Scopes and pa actions share
                // one namespace; duplicates would produce two
                // identically-labeled actions in the compiled flow and
                // misroute any later `on <status> <name>` handler.
                if !declared_targets.insert(name.clone()) {
                    return Err(ResolveError::DuplicateHandlerTarget {
                        name: name.clone(),
                        span: *span,
                    });
                }
                // Read the opaque JSON body from `<source_dir>/pa/<name>.json`.
                let Some(dir) = source_dir else {
                    return Err(ResolveError::PaSourceDirRequired {
                        name: name.clone(),
                        span: *span,
                    });
                };
                let path = dir.join("pa").join(format!("{name}.json"));
                let bytes = std::fs::read(&path).map_err(|_| ResolveError::PaFileNotFound {
                    name: name.clone(),
                    path: path.clone(),
                    span: *span,
                })?;
                let body_json: JsonValue = serde_json::from_slice(&bytes).map_err(|err| {
                    ResolveError::PaFileInvalidJson {
                        name: name.clone(),
                        path: path.clone(),
                        message: err.to_string(),
                        span: *span,
                    }
                })?;
                // The action key on emit defaults to the user-written name,
                // but the round-trip path can override via `pa/flow.json`'s
                // `actionNameMap` so a previously-normalized PA key
                // (`Send_an_email_(V2)` → `Send_an_email_V2` on decode)
                // round-trips back to its original form on emit. Handler
                // lookup keys on the source-level identifier (the user-
                // written `pa <name>`), not the post-override emit name.
                let raw_name = pa_overrides
                    .and_then(|m| m.get(name))
                    .cloned()
                    .unwrap_or_else(|| name.clone());
                let action_name = unique_name(&raw_name, name_counts);
                named_scopes.insert(name.clone(), action_name.clone());
                (action_name, ActionKind::Pa { body_json }, *span)
            }
            Stmt::If {
                condition,
                condition_span,
                true_branch,
                false_branch,
            } => {
                let condition = resolve_expr(condition, env)?;
                let action_name = unique_name(key_prefix::CONDITION, name_counts);
                // Branches scope `let` bindings and named-scope registrations
                // to themselves: save both, resolve each branch against them,
                // restore after. name_counts stays shared since PA action
                // names are globally unique. Rolling back named_scopes here
                // matters because a `scope foo { }` inside a branch creates
                // a PA action nested in the Condition -- letting `foo` leak
                // to outer resolution would allow an `on failed foo` at top
                // level to emit a runAfter targeting an action that isn't
                // at workflow root, which PA rejects on import.
                let saved_env = env.clone();
                let saved_named_scopes = named_scopes.clone();
                let true_actions = resolve_statements(
                    true_branch,
                    env,
                    name_counts,
                    named_scopes,
                    declared_targets,
                    source_dir,
                    pa_overrides,
                    false,
                )?;
                *env = saved_env.clone();
                *named_scopes = saved_named_scopes.clone();
                let false_actions = resolve_statements(
                    false_branch,
                    env,
                    name_counts,
                    named_scopes,
                    declared_targets,
                    source_dir,
                    pa_overrides,
                    false,
                )?;
                *env = saved_env;
                *named_scopes = saved_named_scopes;
                (
                    action_name,
                    ActionKind::Condition {
                        condition,
                        condition_span: *condition_span,
                        true_branch: true_actions,
                        false_branch: false_actions,
                    },
                    *condition_span,
                )
            }
            Stmt::Foreach {
                iter,
                collection,
                body,
                span,
            } => {
                let collection = resolve_expr(collection, env)?;
                // Action key derives from the iterator name so that
                // `foreach thingy in xs` produces action key `thingy`. Makes
                // the iterator name the source of truth for both the binding
                // and the PA action key, which closes the round-trip drift
                // (decoder picks the iterator name from the original PA
                // action key; encoder now reproduces that exact key).
                // Two `foreach thingy in ...` blocks in the same flow get
                // auto-suffixed by `unique_name` (`thingy`, `thingy_2`, ...).
                let action_name = unique_name(iter, name_counts);
                // Iterator and any body-local `let` are scoped to the loop body.
                // Same named_scopes rollback rationale as the Condition arm.
                let saved_env = env.clone();
                let saved_named_scopes = named_scopes.clone();
                env.insert(
                    iter.clone(),
                    Binding::Iterator {
                        action_name: action_name.clone(),
                    },
                );
                let body_actions = resolve_statements(
                    body,
                    env,
                    name_counts,
                    named_scopes,
                    declared_targets,
                    source_dir,
                    pa_overrides,
                    false,
                )?;
                *env = saved_env;
                *named_scopes = saved_named_scopes;
                (
                    action_name,
                    ActionKind::Foreach {
                        collection,
                        iter_name: iter.clone(),
                        body: body_actions,
                    },
                    *span,
                )
            }
            Stmt::Debug { args, span } => {
                let mut resolved_args = Vec::with_capacity(args.len());
                for arg in args {
                    let expr = resolve_expr(&arg.expr, env)?;
                    resolved_args.push(DebugArg {
                        expr,
                        span: arg.span,
                    });
                }
                (
                    String::new(),
                    ActionKind::Debug {
                        args: resolved_args,
                        span: *span,
                    },
                    *span,
                )
            }
            Stmt::Until {
                condition,
                condition_span,
                limit_count,
                limit_count_span,
                limit_timeout,
                body,
                span,
            } => {
                let condition = resolve_expr(condition, env)?;
                // Validate the iteration count now; parser accepts any int
                // literal but PA's Until count is a strictly-positive u32.
                let validated_count = match limit_count {
                    Some(n) => {
                        if *n < 1 || *n > u32::MAX as i64 {
                            return Err(ResolveError::InvalidUntilLimit {
                                value: *n,
                                span: limit_count_span.unwrap_or(*span),
                            });
                        }
                        Some(*n as u32)
                    }
                    None => None,
                };
                let action_name = unique_name(key_prefix::UNTIL, name_counts);
                // Body scopes lets and named-scope registrations to itself,
                // same as foreach/if-branches.
                let saved_env = env.clone();
                let saved_named_scopes = named_scopes.clone();
                let body_actions = resolve_statements(
                    body,
                    env,
                    name_counts,
                    named_scopes,
                    declared_targets,
                    source_dir,
                    pa_overrides,
                    false,
                )?;
                *env = saved_env;
                *named_scopes = saved_named_scopes;
                (
                    action_name,
                    ActionKind::Until {
                        condition,
                        condition_span: *condition_span,
                        limit_count: validated_count,
                        limit_timeout: limit_timeout.clone(),
                        body: body_actions,
                    },
                    *span,
                )
            }
            Stmt::Scope {
                name: scope_name,
                body,
                span,
            } => {
                // Enforce uniqueness across every handler target ever
                // declared in the program. Scopes and raw blocks share one
                // namespace. See the equivalent check in the Raw arm for
                // the rationale (uses the permanent `declared_targets` set
                // so nested duplicates are caught by later siblings).
                if let Some(n) = scope_name
                    && !declared_targets.insert(n.clone())
                {
                    return Err(ResolveError::DuplicateHandlerTarget {
                        name: n.clone(),
                        span: *span,
                    });
                }
                let base = match scope_name {
                    Some(n) => format!("{}_{n}", key_prefix::SCOPE),
                    None => key_prefix::SCOPE.to_string(),
                };
                let action_name = unique_name(&base, name_counts);
                // Register named scopes so `on <status> <name>` handlers can
                // resolve the source label to a PA action name.
                if let Some(n) = scope_name {
                    named_scopes.insert(n.clone(), action_name.clone());
                }
                // Scope body scopes its own `let` bindings and inner named-
                // scope registrations like if/foreach. The scope name we
                // just registered was added BEFORE the clone, so it stays
                // visible to siblings after this scope returns -- only names
                // registered *inside* the body get rolled back.
                let saved_env = env.clone();
                let saved_named_scopes = named_scopes.clone();
                let body_actions = resolve_statements(
                    body,
                    env,
                    name_counts,
                    named_scopes,
                    declared_targets,
                    source_dir,
                    pa_overrides,
                    false,
                )?;
                *env = saved_env;
                *named_scopes = saved_named_scopes;
                (action_name, ActionKind::Scope { body: body_actions }, *span)
            }
            Stmt::OnHandler {
                statuses,
                target,
                target_span,
                body,
                span,
            } => {
                // Parser guarantees `statuses` is non-empty. Reject repeats
                // up front: they usually mean a typo like `failed or failed`.
                let mut seen: Vec<HandlerStatus> = Vec::with_capacity(statuses.len());
                for s in statuses {
                    if seen.contains(s) {
                        return Err(ResolveError::DuplicateHandlerStatus {
                            status: *s,
                            span: *span,
                        });
                    }
                    seen.push(*s);
                }
                let target_action_name = named_scopes.get(target).cloned().ok_or_else(|| {
                    ResolveError::UnknownHandlerTarget {
                        name: target.clone(),
                        span: *target_span,
                    }
                })?;
                // Action name joins status labels in source order, so
                // `on failed or timedout try_work` → `On_failed_timedout_try_work`.
                let labels = statuses
                    .iter()
                    .map(|s| s.as_label())
                    .collect::<Vec<_>>()
                    .join("_");
                let action_name = unique_name(&format!("On_{labels}_{target}"), name_counts);
                // Handler body scopes its own registrations like any other
                // block. Same rationale as Condition/Foreach/Until.
                let saved_env = env.clone();
                let saved_named_scopes = named_scopes.clone();
                let body_actions = resolve_statements(
                    body,
                    env,
                    name_counts,
                    named_scopes,
                    declared_targets,
                    source_dir,
                    pa_overrides,
                    false,
                )?;
                *env = saved_env;
                *named_scopes = saved_named_scopes;
                (
                    action_name,
                    ActionKind::OnHandler {
                        statuses: statuses.clone(),
                        target_action_name,
                        body: body_actions,
                    },
                    *span,
                )
            }
            Stmt::Switch {
                subject,
                subject_span,
                cases,
                default,
                span,
            } => {
                let subject = resolve_expr(subject, env)?;
                let action_name = unique_name(key_prefix::SWITCH, name_counts);
                // Cases and default each scope their `let` bindings and
                // named-scope registrations to their own branch, like
                // if/else-branches. Clone both before each branch and
                // restore after.
                let saved_env = env.clone();
                let saved_named_scopes = named_scopes.clone();
                let mut resolved_cases = Vec::with_capacity(cases.len());
                for case in cases {
                    let case_action_name = unique_name("Case", name_counts);
                    *env = saved_env.clone();
                    *named_scopes = saved_named_scopes.clone();
                    let body = resolve_statements(
                        &case.body,
                        env,
                        name_counts,
                        named_scopes,
                        declared_targets,
                        source_dir,
                        pa_overrides,
                        false,
                    )?;
                    resolved_cases.push(ResolvedSwitchCase {
                        action_name: case_action_name,
                        value: case.value.clone(),
                        body,
                    });
                }
                let resolved_default = match default {
                    Some(stmts) => {
                        *env = saved_env.clone();
                        *named_scopes = saved_named_scopes.clone();
                        Some(resolve_statements(
                            stmts,
                            env,
                            name_counts,
                            named_scopes,
                            declared_targets,
                            source_dir,
                            pa_overrides,
                            false,
                        )?)
                    }
                    None => None,
                };
                *env = saved_env;
                *named_scopes = saved_named_scopes;
                (
                    action_name,
                    ActionKind::Switch {
                        subject,
                        subject_span: *subject_span,
                        cases: resolved_cases,
                        default: resolved_default,
                    },
                    *span,
                )
            }
            Stmt::Terminate {
                status,
                message,
                code,
                span,
            } => {
                let resolved_message = match message {
                    Some(m) => Some(resolve_expr(m, env)?),
                    None => None,
                };
                let resolved_code = match code {
                    Some(c) => Some(resolve_expr(c, env)?),
                    None => None,
                };
                let action_name = unique_name(key_prefix::TERMINATE, name_counts);
                (
                    action_name,
                    ActionKind::Terminate {
                        status: *status,
                        message: resolved_message,
                        code: resolved_code,
                    },
                    *span,
                )
            }
        };

        // Debug actions are diagnostic-only: paxc drops them, and real
        // actions after a debug must chain runAfter back to the last real
        // action, so we skip updating prev_name here.
        if matches!(&kind, ActionKind::Debug { .. }) {
            actions.push(ResolvedAction {
                name: action_name,
                run_after: Vec::new(),
                kind,
                span: stmt_span,
            });
            continue;
        }

        // `on` handlers are off the main sibling chain: their runAfter points
        // at their target + filter statuses (set here), and the statement
        // after the handler chains back to whatever came before, not the
        // handler.
        if let ActionKind::OnHandler {
            statuses,
            target_action_name,
            ..
        } = &kind
        {
            let entry = RunAfterEntry {
                action_name: target_action_name.clone(),
                statuses: statuses.iter().map(|s| s.as_pa_str().to_string()).collect(),
            };
            actions.push(ResolvedAction {
                name: action_name,
                run_after: vec![entry],
                kind,
                span: stmt_span,
            });
            continue;
        }

        let run_after = match &prev_name {
            Some(n) => vec![RunAfterEntry::succeeded(n.clone())],
            None => Vec::new(),
        };
        prev_name = Some(action_name.clone());
        actions.push(ResolvedAction {
            name: action_name,
            run_after,
            kind,
            span: stmt_span,
        });
    }

    Ok(actions)
}

fn lower_assign(
    name: &str,
    name_span: Span,
    op: AssignOp,
    ty: &Type,
    value: Expr,
) -> Result<(String, ActionKind), ResolveError> {
    let invalid = || ResolveError::InvalidOperation {
        op,
        name: name.to_string(),
        ty: ty.clone(),
        span: name_span,
    };
    match op {
        AssignOp::Set => Ok((
            format!("{}_{name}", key_prefix::SET),
            ActionKind::SetVariable {
                var: name.to_string(),
                value,
            },
        )),
        AssignOp::Add => match ty {
            Type::Int | Type::Float => Ok((
                format!("{}_{name}", key_prefix::INCREMENT),
                ActionKind::IncrementVariable {
                    var: name.to_string(),
                    value,
                },
            )),
            Type::Array => Ok((
                format!("{}_{name}", key_prefix::APPEND_TO),
                ActionKind::AppendToArrayVariable {
                    var: name.to_string(),
                    value,
                },
            )),
            _ => Err(invalid()),
        },
        AssignOp::Subtract => match ty {
            Type::Int | Type::Float => Ok((
                format!("{}_{name}", key_prefix::DECREMENT),
                ActionKind::DecrementVariable {
                    var: name.to_string(),
                    value,
                },
            )),
            _ => Err(invalid()),
        },
        AssignOp::Concat => match ty {
            Type::String => Ok((
                format!("{}_{name}", key_prefix::APPEND_TO),
                ActionKind::AppendToStringVariable {
                    var: name.to_string(),
                    value,
                },
            )),
            _ => Err(invalid()),
        },
    }
}

/// Assigns a unique action name using PA's convention: first occurrence bare,
/// subsequent occurrences suffixed `_1`, `_2`, ...
fn unique_name(base: &str, counts: &mut HashMap<String, u32>) -> String {
    let count = counts.entry(base.to_string()).or_insert(0);
    let name = if *count == 0 {
        base.to_string()
    } else {
        format!("{base}_{count}")
    };
    *count += 1;
    name
}

fn resolve_expr(expr: &Expr, env: &HashMap<String, Binding>) -> Result<Expr, ResolveError> {
    match expr {
        Expr::Literal(l) => Ok(Expr::Literal(l.clone())),
        Expr::Ref { name, span } => match env.get(name) {
            Some(Binding::Var { .. }) => Ok(Expr::VarRef {
                name: name.clone(),
                span: *span,
            }),
            Some(Binding::Let { action_name }) => Ok(Expr::ComposeRef {
                action_name: action_name.clone(),
                span: *span,
            }),
            Some(Binding::Iterator { action_name }) => Ok(Expr::IteratorRef {
                action_name: action_name.clone(),
                span: *span,
            }),
            None => Err(ResolveError::UndefinedVariable {
                name: name.clone(),
                span: *span,
            }),
        },
        Expr::Member { target, field } => {
            let resolved_target = resolve_expr(target, env)?;
            Ok(Expr::Member {
                target: Box::new(resolved_target),
                field: field.clone(),
            })
        }
        Expr::Subscript { target, key } => {
            let resolved_target = resolve_expr(target, env)?;
            Ok(Expr::Subscript {
                target: Box::new(resolved_target),
                key: key.clone(),
            })
        }
        Expr::VarRef { .. } | Expr::ComposeRef { .. } | Expr::IteratorRef { .. } => {
            Ok(expr.clone())
        }
        Expr::BinaryOp { op, lhs, rhs } => {
            let lhs = resolve_expr(lhs, env)?;
            let rhs = resolve_expr(rhs, env)?;
            Ok(Expr::BinaryOp {
                op: *op,
                lhs: Box::new(lhs),
                rhs: Box::new(rhs),
            })
        }
        Expr::UnaryOp { op, operand } => {
            let operand = resolve_expr(operand, env)?;
            Ok(Expr::UnaryOp {
                op: *op,
                operand: Box::new(operand),
            })
        }
        Expr::Call { name, args } => {
            let resolved_args: Result<Vec<_>, _> =
                args.iter().map(|a| resolve_expr(a, env)).collect();
            Ok(Expr::Call {
                name: name.clone(),
                args: resolved_args?,
            })
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ast::{Literal, Type};
    use crate::lexer::lexer;
    use crate::parser::parser;
    use chumsky::prelude::*;

    /// Drive a pax source string all the way through lex + parse + resolve
    /// so a test can observe resolve errors that originate in concrete
    /// syntax.
    fn resolve_source(src: &str) -> Result<ResolvedProgram, ResolveError> {
        let tokens = lexer().parse(src).into_result().expect("lex failed");
        let program = parser()
            .parse(
                tokens
                    .as_slice()
                    .map((src.len()..src.len()).into(), |(t, s)| (t, s)),
            )
            .into_result()
            .expect("parse failed");
        resolve(&program, None)
    }

    fn sp() -> Span {
        (0..0).into()
    }

    /// Path to the in-repo fixtures directory used by tests that exercise
    /// the `pa <Name>` resolver path (which reads `pa/<Name>.json` from
    /// the source dir). Each fixture file is a minimal valid JSON object
    /// so the tests can focus on resolver logic, not body content.
    fn fixtures() -> std::path::PathBuf {
        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
    }

    fn rref(name: &str) -> Expr {
        Expr::Ref {
            name: name.to_string(),
            span: sp(),
        }
    }

    fn var(name: &str) -> Stmt {
        Stmt::VarDecl {
            name: name.to_string(),
            name_span: sp(),
            ty: Type::Int,
            value: Some(Expr::Literal(Literal::Int(0))),
        }
    }

    fn var_ty(name: &str, ty: Type) -> Stmt {
        Stmt::VarDecl {
            name: name.to_string(),
            name_span: sp(),
            ty,
            value: Some(Expr::Literal(Literal::Int(0))),
        }
    }

    fn assign(name: &str, op: AssignOp, value: Expr) -> Stmt {
        Stmt::Assign {
            name: name.to_string(),
            name_span: sp(),
            op,
            value,
        }
    }

    fn let_stmt(name: &str, value: Expr) -> Stmt {
        Stmt::Let {
            name: name.to_string(),
            name_span: sp(),
            value,
        }
    }

    fn debug(args: Vec<Expr>) -> Stmt {
        Stmt::Debug {
            args: args
                .into_iter()
                .map(|expr| DebugArg {
                    expr,
                    span: (0..0).into(),
                })
                .collect(),
            span: (0..0).into(),
        }
    }

    #[test]
    fn slice20_debug_does_not_chain_run_after() {
        // var a; debug(); var b --  b.runAfter must point at a, not at debug.
        let prog = Program {
            statements: vec![var("a"), debug(vec![]), var("b")],
        };
        let resolved = resolve(&prog, None).expect("resolve should succeed");
        assert_eq!(resolved.actions.len(), 3);
        assert_eq!(resolved.actions[0].name, "Initialize_a");
        assert!(matches!(resolved.actions[1].kind, ActionKind::Debug { .. }));
        assert!(
            resolved.actions[1].run_after.is_empty(),
            "debug action should have empty runAfter"
        );
        assert_eq!(resolved.actions[2].name, "Initialize_b");
        let after = &resolved.actions[2].run_after;
        assert_eq!(after.len(), 1, "expected one predecessor");
        assert_eq!(
            after[0].action_name, "Initialize_a",
            "action after debug must chain back to prior real action"
        );
        assert_eq!(after[0].statuses, vec!["Succeeded".to_string()]);
    }

    #[test]
    fn chains_in_source_order() {
        let prog = Program {
            statements: vec![var("a"), var("b"), var("c")],
        };
        let resolved = resolve(&prog, None).expect("resolve should succeed");
        assert_eq!(resolved.actions.len(), 3);
        assert_eq!(resolved.actions[0].name, "Initialize_a");
        assert!(resolved.actions[0].run_after.is_empty());
        assert_eq!(resolved.actions[1].name, "Initialize_b");
        assert_eq!(resolved.actions[1].run_after[0].action_name, "Initialize_a");
        assert_eq!(resolved.actions[2].name, "Initialize_c");
        assert_eq!(resolved.actions[2].run_after[0].action_name, "Initialize_b");
    }

    #[test]
    fn rejects_duplicate_variable() {
        let prog = Program {
            statements: vec![var("x"), var("x")],
        };
        assert!(matches!(
            resolve(&prog, None).unwrap_err(),
            ResolveError::DuplicateVariable { name, .. } if name == "x"
        ));
    }

    #[test]
    fn accepts_valid_reference() {
        let ref_y = Stmt::VarDecl {
            name: "y".to_string(),
            name_span: sp(),
            ty: Type::Int,
            value: Some(rref("x")),
        };
        let prog = Program {
            statements: vec![var("x"), ref_y],
        };
        assert!(resolve(&prog, None).is_ok());
    }

    #[test]
    fn rejects_undefined_reference() {
        let ref_y = Stmt::VarDecl {
            name: "y".to_string(),
            name_span: sp(),
            ty: Type::Int,
            value: Some(rref("nope")),
        };
        let prog = Program {
            statements: vec![ref_y],
        };
        assert!(matches!(
            resolve(&prog, None).unwrap_err(),
            ResolveError::UndefinedVariable { name, .. } if name == "nope"
        ));
    }

    #[test]
    fn rejects_forward_reference() {
        let ref_y = Stmt::VarDecl {
            name: "y".to_string(),
            name_span: sp(),
            ty: Type::Int,
            value: Some(rref("x")),
        };
        let prog = Program {
            statements: vec![ref_y, var("x")],
        };
        assert!(matches!(
            resolve(&prog, None).unwrap_err(),
            ResolveError::UndefinedVariable { name, .. } if name == "x"
        ));
    }

    #[test]
    fn set_on_any_type() {
        let prog = Program {
            statements: vec![
                var_ty("x", Type::Bool),
                assign("x", AssignOp::Set, Expr::Literal(Literal::Bool(true))),
            ],
        };
        let resolved = resolve(&prog, None).unwrap();
        assert!(matches!(
            resolved.actions[1].kind,
            ActionKind::SetVariable { .. }
        ));
        assert_eq!(resolved.actions[1].name, "Set_x");
    }

    #[test]
    fn add_dispatches_by_type() {
        let cases = [
            (Type::Int, "Increment_x"),
            (Type::Float, "Increment_x"),
            (Type::Array, "Append_to_x"),
        ];
        for (ty, expected_name) in cases {
            let prog = Program {
                statements: vec![
                    var_ty("x", ty.clone()),
                    assign("x", AssignOp::Add, Expr::Literal(Literal::Int(1))),
                ],
            };
            let resolved = resolve(&prog, None).unwrap();
            assert_eq!(resolved.actions[1].name, expected_name);
        }
    }

    #[test]
    fn add_rejects_string_bool_and_object() {
        for ty in [Type::String, Type::Bool, Type::Object] {
            let prog = Program {
                statements: vec![
                    var_ty("x", ty.clone()),
                    assign("x", AssignOp::Add, Expr::Literal(Literal::Int(1))),
                ],
            };
            assert!(matches!(
                resolve(&prog, None).unwrap_err(),
                ResolveError::InvalidOperation { .. }
            ));
        }
    }

    #[test]
    fn concat_assign_dispatches_to_string_append() {
        let prog = Program {
            statements: vec![
                var_ty("msg", Type::String),
                assign(
                    "msg",
                    AssignOp::Concat,
                    Expr::Literal(Literal::String("!".to_string())),
                ),
            ],
        };
        let resolved = resolve(&prog, None).unwrap();
        assert_eq!(resolved.actions[1].name, "Append_to_msg");
        assert!(matches!(
            resolved.actions[1].kind,
            ActionKind::AppendToStringVariable { .. }
        ));
    }

    #[test]
    fn concat_assign_rejects_non_string() {
        for ty in [
            Type::Int,
            Type::Float,
            Type::Bool,
            Type::Array,
            Type::Object,
        ] {
            let prog = Program {
                statements: vec![
                    var_ty("x", ty.clone()),
                    assign(
                        "x",
                        AssignOp::Concat,
                        Expr::Literal(Literal::String("".to_string())),
                    ),
                ],
            };
            assert!(matches!(
                resolve(&prog, None).unwrap_err(),
                ResolveError::InvalidOperation { .. }
            ));
        }
    }

    #[test]
    fn subtract_only_on_numeric() {
        // String rejected; Int and Float accepted.
        let prog = Program {
            statements: vec![
                var_ty("x", Type::String),
                assign("x", AssignOp::Subtract, Expr::Literal(Literal::Int(1))),
            ],
        };
        assert!(matches!(
            resolve(&prog, None).unwrap_err(),
            ResolveError::InvalidOperation { .. }
        ));
        for ty in [Type::Int, Type::Float] {
            let prog = Program {
                statements: vec![
                    var_ty("x", ty),
                    assign("x", AssignOp::Subtract, Expr::Literal(Literal::Int(1))),
                ],
            };
            let resolved = resolve(&prog, None).unwrap();
            assert_eq!(resolved.actions[1].name, "Decrement_x");
        }
    }

    #[test]
    fn assign_to_undefined_is_error() {
        let prog = Program {
            statements: vec![assign(
                "nope",
                AssignOp::Set,
                Expr::Literal(Literal::Int(1)),
            )],
        };
        assert!(matches!(
            resolve(&prog, None).unwrap_err(),
            ResolveError::UndefinedVariable { name, .. } if name == "nope"
        ));
    }

    #[test]
    fn auto_suffix_follows_zero_indexed_convention() {
        let prog = Program {
            statements: vec![
                var_ty("x", Type::Int),
                assign("x", AssignOp::Add, Expr::Literal(Literal::Int(1))),
                assign("x", AssignOp::Add, Expr::Literal(Literal::Int(1))),
                assign("x", AssignOp::Add, Expr::Literal(Literal::Int(1))),
            ],
        };
        let resolved = resolve(&prog, None).unwrap();
        assert_eq!(resolved.actions[1].name, "Increment_x");
        assert_eq!(resolved.actions[2].name, "Increment_x_1");
        assert_eq!(resolved.actions[3].name, "Increment_x_2");
    }

    #[test]
    fn let_becomes_compose_action() {
        let prog = Program {
            statements: vec![let_stmt("doubled", Expr::Literal(Literal::Int(42)))],
        };
        let resolved = resolve(&prog, None).unwrap();
        assert_eq!(resolved.actions[0].name, "Compose_doubled");
        assert!(matches!(
            resolved.actions[0].kind,
            ActionKind::Compose { .. }
        ));
    }

    #[test]
    fn let_is_referenced_as_compose_output() {
        let prog = Program {
            statements: vec![
                let_stmt("total", Expr::Literal(Literal::Int(10))),
                Stmt::VarDecl {
                    name: "mirror".to_string(),
                    name_span: sp(),
                    ty: Type::Int,
                    value: Some(rref("total")),
                },
            ],
        };
        let resolved = resolve(&prog, None).unwrap();
        let var_action = &resolved.actions[1];
        match &var_action.kind {
            ActionKind::InitializeVariable { value, .. } => {
                assert!(
                    matches!(value, Some(Expr::ComposeRef { action_name, .. }) if action_name == "Compose_total")
                );
            }
            _ => panic!("expected InitializeVariable"),
        }
    }

    #[test]
    fn cannot_assign_to_let() {
        let prog = Program {
            statements: vec![
                let_stmt("immut", Expr::Literal(Literal::Int(1))),
                assign("immut", AssignOp::Set, Expr::Literal(Literal::Int(2))),
            ],
        };
        assert!(matches!(
            resolve(&prog, None).unwrap_err(),
            ResolveError::CannotAssignToImmutable { name, .. } if name == "immut"
        ));
    }

    #[test]
    fn nested_var_decl_in_if_branch_is_error() {
        let prog = Program {
            statements: vec![
                var_ty("flag", Type::Bool),
                Stmt::If {
                    condition: rref("flag"),
                    condition_span: sp(),
                    true_branch: vec![var("inner")],
                    false_branch: vec![],
                },
            ],
        };
        assert!(matches!(
            resolve(&prog, None).unwrap_err(),
            ResolveError::NestedVarDeclaration { name, .. } if name == "inner"
        ));
    }

    #[test]
    fn nested_var_decl_in_foreach_body_is_error() {
        let prog = Program {
            statements: vec![
                var_ty("items", Type::Array),
                Stmt::Foreach {
                    iter: "x".to_string(),
                    collection: rref("items"),
                    body: vec![var("inner")],
                    span: sp(),
                },
            ],
        };
        assert!(matches!(
            resolve(&prog, None).unwrap_err(),
            ResolveError::NestedVarDeclaration { name, .. } if name == "inner"
        ));
    }

    #[test]
    fn let_in_if_branch_does_not_leak_to_outer_scope() {
        let prog = Program {
            statements: vec![
                var_ty("flag", Type::Bool),
                Stmt::If {
                    condition: rref("flag"),
                    condition_span: sp(),
                    true_branch: vec![let_stmt("inner", Expr::Literal(Literal::Int(1)))],
                    false_branch: vec![],
                },
                // Reference `inner` from outer scope: should fail, because
                // the `let` was scoped to the true branch.
                let_stmt("leak", rref("inner")),
            ],
        };
        assert!(matches!(
            resolve(&prog, None).unwrap_err(),
            ResolveError::UndefinedVariable { name, .. } if name == "inner"
        ));
    }

    #[test]
    fn let_in_true_branch_does_not_leak_to_false_branch() {
        let prog = Program {
            statements: vec![
                var_ty("flag", Type::Bool),
                Stmt::If {
                    condition: rref("flag"),
                    condition_span: sp(),
                    true_branch: vec![let_stmt("inner", Expr::Literal(Literal::Int(1)))],
                    false_branch: vec![let_stmt("copy", rref("inner"))],
                },
            ],
        };
        assert!(matches!(
            resolve(&prog, None).unwrap_err(),
            ResolveError::UndefinedVariable { name, .. } if name == "inner"
        ));
    }

    #[test]
    fn let_in_foreach_body_does_not_leak_to_outer_scope() {
        let prog = Program {
            statements: vec![
                var_ty("items", Type::Array),
                Stmt::Foreach {
                    iter: "x".to_string(),
                    collection: rref("items"),
                    body: vec![let_stmt("per_item", Expr::Literal(Literal::Int(1)))],
                    span: sp(),
                },
                let_stmt("leak", rref("per_item")),
            ],
        };
        assert!(matches!(
            resolve(&prog, None).unwrap_err(),
            ResolveError::UndefinedVariable { name, .. } if name == "per_item"
        ));
    }

    #[test]
    fn nested_let_can_still_reference_outer_vars() {
        // Regression guard: scoping a nested `let` must not break its ability
        // to see outer-scope declarations.
        let prog = Program {
            statements: vec![
                var_ty("outer", Type::Int),
                Stmt::If {
                    condition: Expr::Literal(Literal::Bool(true)),
                    condition_span: sp(),
                    true_branch: vec![let_stmt("copy", rref("outer"))],
                    false_branch: vec![],
                },
            ],
        };
        assert!(resolve(&prog, None).is_ok());
    }

    #[test]
    fn slice30_on_handler_resolves_target_to_action_name() {
        let prog = Program {
            statements: vec![
                Stmt::Scope {
                    name: Some("try_work".to_string()),
                    body: vec![],
                    span: sp(),
                },
                Stmt::OnHandler {
                    statuses: vec![HandlerStatus::Failed],
                    target: "try_work".to_string(),
                    target_span: sp(),
                    body: vec![],
                    span: sp(),
                },
            ],
        };
        let resolved = resolve(&prog, None).unwrap();
        assert_eq!(resolved.actions[0].name, "Scope_try_work");
        assert_eq!(resolved.actions[1].name, "On_failed_try_work");
        match &resolved.actions[1].kind {
            ActionKind::OnHandler {
                statuses,
                target_action_name,
                ..
            } => {
                assert_eq!(statuses, &vec![HandlerStatus::Failed]);
                assert_eq!(target_action_name, "Scope_try_work");
            }
            _ => panic!("expected OnHandler"),
        }
        // RunAfter points at Scope_try_work with Failed status.
        assert_eq!(resolved.actions[1].run_after.len(), 1);
        assert_eq!(
            resolved.actions[1].run_after[0].action_name,
            "Scope_try_work"
        );
        assert_eq!(
            resolved.actions[1].run_after[0].statuses,
            vec!["Failed".to_string()]
        );
    }

    #[test]
    fn slice30_unknown_handler_target_errors() {
        let prog = Program {
            statements: vec![Stmt::OnHandler {
                statuses: vec![HandlerStatus::Failed],
                target: "nope".to_string(),
                target_span: sp(),
                body: vec![],
                span: sp(),
            }],
        };
        assert!(matches!(
            resolve(&prog, None).unwrap_err(),
            ResolveError::UnknownHandlerTarget { name, .. } if name == "nope"
        ));
    }

    #[test]
    fn slice30_nested_scope_name_does_not_leak_to_outer_handler() {
        // Regression guard: a scope declared inside an if / foreach / switch
        // case / until / scope / on-handler body must not be visible as a
        // target from the enclosing source level. Letting `conditional_work`
        // leak would emit an `on` handler at workflow root pointing at a
        // Scope action nested inside the Condition -- invalid PA graph.
        let prog = Program {
            statements: vec![
                var_ty("flag", Type::Bool),
                Stmt::If {
                    condition: rref("flag"),
                    condition_span: sp(),
                    true_branch: vec![Stmt::Scope {
                        name: Some("conditional_work".to_string()),
                        body: vec![],
                        span: sp(),
                    }],
                    false_branch: vec![],
                },
                Stmt::OnHandler {
                    statuses: vec![HandlerStatus::Failed],
                    target: "conditional_work".to_string(),
                    target_span: sp(),
                    body: vec![],
                    span: sp(),
                },
            ],
        };
        assert!(matches!(
            resolve(&prog, None).unwrap_err(),
            ResolveError::UnknownHandlerTarget { name, .. } if name == "conditional_work"
        ));
    }

    #[test]
    fn slice30_handler_within_same_scope_body_still_resolves() {
        // Complement to the leak test: a handler that references a sibling
        // scope declared in the SAME body resolves fine. Named scopes are
        // visible to siblings within a block; they're rolled back only at
        // the end of the enclosing block.
        let prog = Program {
            statements: vec![Stmt::Scope {
                name: Some("outer".to_string()),
                body: vec![
                    Stmt::Scope {
                        name: Some("inner".to_string()),
                        body: vec![],
                        span: sp(),
                    },
                    Stmt::OnHandler {
                        statuses: vec![HandlerStatus::Failed],
                        target: "inner".to_string(),
                        target_span: sp(),
                        body: vec![],
                        span: sp(),
                    },
                ],
                span: sp(),
            }],
        };
        assert!(
            resolve(&prog, None).is_ok(),
            "handler on sibling inner scope should resolve"
        );
    }

    #[test]
    fn slice30_handler_does_not_participate_in_sibling_chain() {
        // A handler between two regular actions must not appear in the next
        // action's runAfter. The next action chains back to the scope (the
        // last real action on the main path).
        let prog = Program {
            statements: vec![
                Stmt::Scope {
                    name: Some("work".to_string()),
                    body: vec![],
                    span: sp(),
                },
                Stmt::OnHandler {
                    statuses: vec![HandlerStatus::Failed],
                    target: "work".to_string(),
                    target_span: sp(),
                    body: vec![],
                    span: sp(),
                },
                var("after"),
            ],
        };
        let resolved = resolve(&prog, None).unwrap();
        assert_eq!(resolved.actions[2].name, "Initialize_after");
        assert_eq!(resolved.actions[2].run_after.len(), 1);
        assert_eq!(
            resolved.actions[2].run_after[0].action_name, "Scope_work",
            "next action must chain to the scope, not the handler"
        );
    }

    #[test]
    fn slice32_multi_status_handler_joins_statuses_in_runafter() {
        let prog = Program {
            statements: vec![
                Stmt::Scope {
                    name: Some("try_work".to_string()),
                    body: vec![],
                    span: sp(),
                },
                Stmt::OnHandler {
                    statuses: vec![HandlerStatus::Failed, HandlerStatus::TimedOut],
                    target: "try_work".to_string(),
                    target_span: sp(),
                    body: vec![],
                    span: sp(),
                },
            ],
        };
        let resolved = resolve(&prog, None).unwrap();
        assert_eq!(
            resolved.actions[1].name, "On_failed_timedout_try_work",
            "action name joins status labels in source order"
        );
        match &resolved.actions[1].kind {
            ActionKind::OnHandler { statuses, .. } => {
                assert_eq!(
                    statuses,
                    &vec![HandlerStatus::Failed, HandlerStatus::TimedOut]
                );
            }
            _ => panic!("expected OnHandler"),
        }
        let entry = &resolved.actions[1].run_after[0];
        assert_eq!(entry.action_name, "Scope_try_work");
        assert_eq!(
            entry.statuses,
            vec!["Failed".to_string(), "TimedOut".to_string()],
            "both statuses appear in PA-capitalized form, source order preserved"
        );
    }

    #[test]
    fn slice35_duplicate_scope_name_is_resolve_error() {
        let prog = Program {
            statements: vec![
                Stmt::Scope {
                    name: Some("work".to_string()),
                    body: vec![],
                    span: sp(),
                },
                Stmt::Scope {
                    name: Some("work".to_string()),
                    body: vec![],
                    span: sp(),
                },
            ],
        };
        assert!(matches!(
            resolve(&prog, None).unwrap_err(),
            ResolveError::DuplicateHandlerTarget { name, .. } if name == "work"
        ));
    }

    #[test]
    fn slice35_duplicate_pa_name_is_resolve_error() {
        let prog = Program {
            statements: vec![
                Stmt::Pa {
                    name: "HTTP_Call".to_string(),
                    name_span: sp(),
                    span: sp(),
                },
                Stmt::Pa {
                    name: "HTTP_Call".to_string(),
                    name_span: sp(),
                    span: sp(),
                },
            ],
        };
        assert!(matches!(
            resolve(&prog, Some(&fixtures())).unwrap_err(),
            ResolveError::DuplicateHandlerTarget { name, .. } if name == "HTTP_Call"
        ));
    }

    #[test]
    fn slice35_scope_and_pa_share_namespace() {
        // `scope foo` followed by `pa foo` also collides -- both are
        // handler targets and share one name registry.
        let prog = Program {
            statements: vec![
                Stmt::Scope {
                    name: Some("foo".to_string()),
                    body: vec![],
                    span: sp(),
                },
                Stmt::Pa {
                    name: "foo".to_string(),
                    name_span: sp(),
                    span: sp(),
                },
            ],
        };
        assert!(matches!(
            resolve(&prog, Some(&fixtures())).unwrap_err(),
            ResolveError::DuplicateHandlerTarget { name, .. } if name == "foo"
        ));
    }

    #[test]
    fn slice35_unnamed_scope_does_not_collide() {
        // Anonymous `scope { }` doesn't register in the handler-target map,
        // so multiple anonymous scopes remain fine.
        let prog = Program {
            statements: vec![
                Stmt::Scope {
                    name: None,
                    body: vec![],
                    span: sp(),
                },
                Stmt::Scope {
                    name: None,
                    body: vec![],
                    span: sp(),
                },
            ],
        };
        assert!(resolve(&prog, None).is_ok());
    }

    #[test]
    fn slice35_duplicate_across_if_branches_is_resolve_error() {
        // Scope/raw names are globally unique across the program, regardless
        // of nesting. Two scopes with the same name -- even in mutually-
        // exclusive branches -- are a resolve error. The strict rule keeps
        // the mental model simple: every named handler target is unique in
        // the source text.
        let prog = Program {
            statements: vec![
                var_ty("flag", Type::Bool),
                Stmt::If {
                    condition: rref("flag"),
                    condition_span: sp(),
                    true_branch: vec![Stmt::Scope {
                        name: Some("path".to_string()),
                        body: vec![],
                        span: sp(),
                    }],
                    false_branch: vec![Stmt::Scope {
                        name: Some("path".to_string()),
                        body: vec![],
                        span: sp(),
                    }],
                },
            ],
        };
        assert!(matches!(
            resolve(&prog, None).unwrap_err(),
            ResolveError::DuplicateHandlerTarget { name, .. } if name == "path"
        ));
    }

    #[test]
    fn slice35_nested_then_outer_same_name_is_resolve_error() {
        // Regression for a code-review finding: when `scope foo` inside an
        // if-branch is followed by a second `scope foo` at the enclosing
        // level, the rollback of the inner registration previously let the
        // outer duplicate slip through. With the permanent
        // `declared_targets` set it's correctly rejected.
        let prog = Program {
            statements: vec![
                var_ty("flag", Type::Bool),
                Stmt::If {
                    condition: rref("flag"),
                    condition_span: sp(),
                    true_branch: vec![Stmt::Scope {
                        name: Some("foo".to_string()),
                        body: vec![],
                        span: sp(),
                    }],
                    false_branch: vec![],
                },
                Stmt::Scope {
                    name: Some("foo".to_string()),
                    body: vec![],
                    span: sp(),
                },
            ],
        };
        assert!(matches!(
            resolve(&prog, None).unwrap_err(),
            ResolveError::DuplicateHandlerTarget { name, .. } if name == "foo"
        ));
    }

    #[test]
    fn slice34_until_max_zero_is_resolve_error() {
        let prog = Program {
            statements: vec![Stmt::Until {
                condition: Expr::Literal(Literal::Bool(false)),
                condition_span: sp(),
                limit_count: Some(0),
                limit_count_span: Some(sp()),
                limit_timeout: None,
                body: vec![],
                span: sp(),
            }],
        };
        assert!(matches!(
            resolve(&prog, None).unwrap_err(),
            ResolveError::InvalidUntilLimit { value: 0, .. }
        ));
    }

    #[test]
    fn slice34_until_max_zero_via_source_surfaces_diagnostic() {
        // End-to-end coverage for a code-review finding: the parser accepts
        // any Token::Int(n) for `max N`, so zero and negatives make it to
        // the resolver. This asserts the resolver's range check fires and
        // that the error span is the `max 0` clause (not the whole statement).
        let err = resolve_source("var n: int = 0\nuntil n > 5 max 0 { n += 1 }").unwrap_err();
        assert!(matches!(
            err,
            ResolveError::InvalidUntilLimit { value: 0, .. }
        ));
        // Span should come from the `0` literal (the limit-count span set
        // by the parser), not the statement span fallback.
        let span = err.span();
        assert!(span.end > span.start, "span should be non-empty");
    }

    #[test]
    fn slice34_until_max_negative_is_resolve_error() {
        let prog = Program {
            statements: vec![Stmt::Until {
                condition: Expr::Literal(Literal::Bool(false)),
                condition_span: sp(),
                limit_count: Some(-5),
                limit_count_span: Some(sp()),
                limit_timeout: None,
                body: vec![],
                span: sp(),
            }],
        };
        assert!(matches!(
            resolve(&prog, None).unwrap_err(),
            ResolveError::InvalidUntilLimit { value: -5, .. }
        ));
    }

    #[test]
    fn slice33_on_handler_targets_a_pa_action() {
        let prog = Program {
            statements: vec![
                Stmt::Pa {
                    name: "HTTP_Call".to_string(),
                    name_span: sp(),
                    span: sp(),
                },
                Stmt::OnHandler {
                    statuses: vec![HandlerStatus::Failed],
                    target: "HTTP_Call".to_string(),
                    target_span: sp(),
                    body: vec![],
                    span: sp(),
                },
            ],
        };
        let resolved = resolve(&prog, Some(&fixtures())).unwrap();
        assert_eq!(resolved.actions[0].name, "HTTP_Call");
        assert_eq!(resolved.actions[1].name, "On_failed_HTTP_Call");
        match &resolved.actions[1].kind {
            ActionKind::OnHandler {
                target_action_name, ..
            } => {
                assert_eq!(target_action_name, "HTTP_Call");
            }
            _ => panic!("expected OnHandler"),
        }
        assert_eq!(
            resolved.actions[1].run_after[0].action_name, "HTTP_Call",
            "runAfter points at the pa action by its PA action name"
        );
    }

    #[test]
    fn slice33_unknown_handler_target_error_mentions_pa_too() {
        let prog = Program {
            statements: vec![Stmt::OnHandler {
                statuses: vec![HandlerStatus::Failed],
                target: "nope".to_string(),
                target_span: sp(),
                body: vec![],
                span: sp(),
            }],
        };
        let err = resolve(&prog, None).unwrap_err();
        let msg = format!("{err}");
        assert!(
            msg.contains("pa"),
            "error message should mention pa actions: {msg}"
        );
    }

    #[test]
    fn slice32_duplicate_handler_status_errors() {
        let prog = Program {
            statements: vec![
                Stmt::Scope {
                    name: Some("try_work".to_string()),
                    body: vec![],
                    span: sp(),
                },
                Stmt::OnHandler {
                    statuses: vec![HandlerStatus::Failed, HandlerStatus::Failed],
                    target: "try_work".to_string(),
                    target_span: sp(),
                    body: vec![],
                    span: sp(),
                },
            ],
        };
        assert!(matches!(
            resolve(&prog, None).unwrap_err(),
            ResolveError::DuplicateHandlerStatus { status, .. }
                if status == HandlerStatus::Failed
        ));
    }

    #[test]
    fn slice28_scope_unnamed_gets_bare_action_key() {
        let prog = Program {
            statements: vec![Stmt::Scope {
                name: None,
                body: vec![],
                span: sp(),
            }],
        };
        let resolved = resolve(&prog, None).unwrap();
        assert_eq!(resolved.actions[0].name, "Scope");
        assert!(matches!(resolved.actions[0].kind, ActionKind::Scope { .. }));
    }

    #[test]
    fn slice28_scope_named_keys_by_name() {
        let prog = Program {
            statements: vec![Stmt::Scope {
                name: Some("try_api".to_string()),
                body: vec![],
                span: sp(),
            }],
        };
        let resolved = resolve(&prog, None).unwrap();
        assert_eq!(resolved.actions[0].name, "Scope_try_api");
    }

    #[test]
    fn slice28_scope_nested_var_decl_is_error() {
        // Like if/foreach, Scope bodies reject nested `var`.
        let prog = Program {
            statements: vec![Stmt::Scope {
                name: None,
                body: vec![var("inner")],
                span: sp(),
            }],
        };
        assert!(matches!(
            resolve(&prog, None).unwrap_err(),
            ResolveError::NestedVarDeclaration { name, .. } if name == "inner"
        ));
    }

    #[test]
    fn slice28_scope_let_does_not_leak() {
        let prog = Program {
            statements: vec![
                Stmt::Scope {
                    name: None,
                    body: vec![let_stmt("inner", Expr::Literal(Literal::Int(1)))],
                    span: sp(),
                },
                let_stmt("leak", rref("inner")),
            ],
        };
        assert!(matches!(
            resolve(&prog, None).unwrap_err(),
            ResolveError::UndefinedVariable { name, .. } if name == "inner"
        ));
    }

    #[test]
    fn slice27_switch_resolves_with_unique_case_names() {
        // Multiple cases should get auto-suffixed action names via the shared
        // name counter, parallel to PA's Case / Case_1 convention.
        let prog = Program {
            statements: vec![
                var_ty("status", Type::String),
                Stmt::Switch {
                    subject: rref("status"),
                    subject_span: sp(),
                    cases: vec![
                        crate::ast::SwitchCase {
                            value: Literal::String("a".to_string()),
                            body: vec![],
                            span: sp(),
                        },
                        crate::ast::SwitchCase {
                            value: Literal::String("b".to_string()),
                            body: vec![],
                            span: sp(),
                        },
                    ],
                    default: None,
                    span: sp(),
                },
            ],
        };
        let resolved = resolve(&prog, None).unwrap();
        match &resolved.actions[1].kind {
            ActionKind::Switch { cases, default, .. } => {
                assert_eq!(cases[0].action_name, "Case");
                assert_eq!(cases[1].action_name, "Case_1");
                assert!(default.is_none(), "empty default source -> None");
            }
            _ => panic!("expected Switch"),
        }
    }

    #[test]
    fn slice27_switch_nested_var_decl_is_error() {
        // `var` in a case body must be rejected like in if/foreach bodies.
        let prog = Program {
            statements: vec![
                var_ty("status", Type::String),
                Stmt::Switch {
                    subject: rref("status"),
                    subject_span: sp(),
                    cases: vec![crate::ast::SwitchCase {
                        value: Literal::String("a".to_string()),
                        body: vec![var("inner")],
                        span: sp(),
                    }],
                    default: None,
                    span: sp(),
                },
            ],
        };
        assert!(matches!(
            resolve(&prog, None).unwrap_err(),
            ResolveError::NestedVarDeclaration { name, .. } if name == "inner"
        ));
    }

    #[test]
    fn slice27_switch_let_in_case_does_not_leak() {
        // Regression: per-case `let` bindings must scope to their own branch,
        // paralleling if-branch scoping.
        let prog = Program {
            statements: vec![
                var_ty("status", Type::String),
                Stmt::Switch {
                    subject: rref("status"),
                    subject_span: sp(),
                    cases: vec![crate::ast::SwitchCase {
                        value: Literal::String("a".to_string()),
                        body: vec![let_stmt("inner", Expr::Literal(Literal::Int(1)))],
                        span: sp(),
                    }],
                    default: None,
                    span: sp(),
                },
                let_stmt("leak", rref("inner")),
            ],
        };
        assert!(matches!(
            resolve(&prog, None).unwrap_err(),
            ResolveError::UndefinedVariable { name, .. } if name == "inner"
        ));
    }

    #[test]
    fn var_and_let_share_namespace() {
        let prog = Program {
            statements: vec![var("x"), let_stmt("x", Expr::Literal(Literal::Int(1)))],
        };
        assert!(matches!(
            resolve(&prog, None).unwrap_err(),
            ResolveError::DuplicateVariable { name, .. } if name == "x"
        ));
    }

    /// Helper: extract the value-expression embedded in the n-th resolved
    /// action when it is `InitializeVariable`. Panics if the action is not
    /// `InitializeVariable` or if the variable was declared without an
    /// initializer.
    fn init_value(p: &ResolvedProgram, idx: usize) -> &Expr {
        match &p.actions[idx].kind {
            ActionKind::InitializeVariable {
                value: Some(value), ..
            } => value,
            ActionKind::InitializeVariable { value: None, .. } => {
                panic!("expected InitializeVariable with initializer, got no-initializer form")
            }
            other => panic!("expected InitializeVariable, got {other:?}"),
        }
    }

    #[test]
    fn varref_carries_originating_identifier_span() {
        // The reference `total` on the second line is what should land in
        // the resolved action; its span should match the identifier's
        // location in source, not the enclosing action.
        let src = "var total: int = 5\nvar mirror: int = total\n";
        let resolved = resolve_source(src).unwrap();
        let value = init_value(&resolved, 1);
        match value {
            Expr::VarRef { name, span } => {
                assert_eq!(name, "total");
                let prefix = "var total: int = 5\nvar mirror: int = ";
                let expected_start = prefix.len();
                assert_eq!(span.start, expected_start);
                assert_eq!(span.end, expected_start + "total".len());
            }
            other => panic!("expected VarRef, got {other:?}"),
        }
    }

    #[test]
    fn composeref_carries_originating_identifier_span() {
        let src = "let total = 5\nvar mirror: int = total\n";
        let resolved = resolve_source(src).unwrap();
        let value = init_value(&resolved, 1);
        match value {
            Expr::ComposeRef { action_name, span } => {
                assert_eq!(action_name, "Compose_total");
                let prefix = "let total = 5\nvar mirror: int = ";
                let expected_start = prefix.len();
                assert_eq!(span.start, expected_start);
                assert_eq!(span.end, expected_start + "total".len());
            }
            other => panic!("expected ComposeRef, got {other:?}"),
        }
    }

    /// Pin the sibling-chain status string so a future regression that
    /// drifts away from PA's `Succeeded` capitalization breaks loudly.
    /// Centralizing the constant in `pa::names::status` makes drift
    /// less likely, but the test guards against an accidental edit
    /// either there or in `RunAfterEntry::succeeded`.
    #[test]
    fn sibling_chain_runafter_uses_pa_canonical_succeeded() {
        let resolved = resolve_source("var a: int = 1\nvar b: int = 2\n").unwrap();
        assert_eq!(resolved.actions[1].run_after.len(), 1);
        assert_eq!(
            resolved.actions[1].run_after[0].statuses,
            vec!["Succeeded".to_string()]
        );
    }
}
