//! GitHub Actions workflow models.
//!
//! These models enrich the models under [`github_actions_models::workflow`],
//! providing higher-level APIs for zizmor to use.

use github_actions_expressions::context::{self};
use github_actions_models::{
    common::{self, expr::LoE},
    workflow::{
        self, Trigger,
        event::{BareEvent, OptionalBody},
        job,
    },
};
use terminal_link::Link;

pub(crate) mod matrix;

use crate::{
    InputKey,
    finding::location::{Locatable, SymbolicFeature, SymbolicLocation},
    models::{
        AsDocument, StepBodyCommon, StepCommon,
        inputs::{Capability, HasInputs},
        workflow::matrix::Matrix,
    },
    registry::input::CollectionError,
    utils::{self, WORKFLOW_VALIDATOR, from_str_with_validation, once::warn_once},
};

/// Represents an entire GitHub Actions workflow.
///
/// This type implements [`std::ops::Deref`] for [`workflow::Workflow`],
/// providing access to the underlying data model.
pub(crate) struct Workflow {
    /// This workflow's unique key into zizmor's runtime registry.
    pub(crate) key: InputKey,
    /// A clickable (OSC 8) link to this workflow, if remote.
    pub(crate) link: Option<String>,
    document: yamlpath::Document,
    inner: workflow::Workflow,
}

impl<'a> AsDocument<'a, 'a> for Workflow {
    fn as_document(&'a self) -> &'a yamlpath::Document {
        &self.document
    }
}

impl std::fmt::Debug for Workflow {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{key}", key = self.key)
    }
}

impl std::ops::Deref for Workflow {
    type Target = workflow::Workflow;

    fn deref(&self) -> &Self::Target {
        &self.inner
    }
}

impl HasInputs for workflow::event::WorkflowCall {
    fn get_input(&self, name: &str) -> Option<Capability> {
        let input = self.inputs.get(name)?;

        Some(match input.r#type {
            workflow::event::WorkflowCallInputType::Boolean => Capability::Fixed,
            workflow::event::WorkflowCallInputType::Number => Capability::Fixed,
            workflow::event::WorkflowCallInputType::String => Capability::Arbitrary,
        })
    }
}

impl HasInputs for workflow::event::WorkflowDispatch {
    fn get_input(&self, name: &str) -> Option<Capability> {
        let input = self.inputs.get(name)?;

        Some(match input.r#type {
            workflow::event::WorkflowDispatchInputType::Boolean => Capability::Fixed,
            workflow::event::WorkflowDispatchInputType::Choice => Capability::Fixed,
            workflow::event::WorkflowDispatchInputType::Environment => Capability::Fixed,
            workflow::event::WorkflowDispatchInputType::Number => Capability::Fixed,
            workflow::event::WorkflowDispatchInputType::String => Capability::Arbitrary,
        })
    }
}

impl HasInputs for Workflow {
    fn get_input(&self, name: &str) -> Option<Capability> {
        let workflow::Trigger::Events(events) = &self.on else {
            return None;
        };

        let wc_cap = {
            if let workflow::event::OptionalBody::Body(wc) = &events.workflow_call {
                wc.get_input(name)
            } else {
                None
            }
        };

        let wd_cap = {
            if let workflow::event::OptionalBody::Body(wd) = &events.workflow_dispatch {
                wd.get_input(name)
            } else {
                None
            }
        };

        match (wc_cap, wd_cap) {
            (Some(cap1), Some(cap2)) => Some(cap1.unify(cap2)),
            (Some(single), None) | (None, Some(single)) => Some(single),
            (None, None) => None,
        }
    }
}

impl Workflow {
    /// Load a workflow from a buffer, with an assigned name.
    pub(crate) fn from_string(contents: String, key: InputKey) -> Result<Self, CollectionError> {
        let inner = from_str_with_validation(&contents, &WORKFLOW_VALIDATOR)?;

        let document = yamlpath::Document::new(&contents)?;

        let link = match key {
            InputKey::Local(_) | InputKey::Stdin(_) => None,
            InputKey::Remote(_) => {
                // NOTE: InputKey's Display produces a URL, hence `key.to_string()`.
                Some(Link::new(key.presentation_path(), &key.to_string()).to_string())
            }
        };

        Ok(Self {
            link,
            key,
            document,
            inner,
        })
    }

    /// A [`Jobs`] iterator over this workflow's constituent [`Job`]s.
    pub(crate) fn jobs(&self) -> Jobs<'_> {
        Jobs::new(self)
    }

    /// Whether this workflow is triggered by pull_request_target.
    pub(crate) fn has_pull_request_target(&self) -> bool {
        match &self.on {
            Trigger::BareEvent(event) => *event == BareEvent::PullRequestTarget,
            Trigger::BareEvents(events) => events.contains(&BareEvent::PullRequestTarget),
            Trigger::Events(events) => !matches!(events.pull_request_target, OptionalBody::Missing),
        }
    }

    /// Whether this workflow is triggered by workflow_run.
    pub(crate) fn has_workflow_run(&self) -> bool {
        match &self.on {
            Trigger::BareEvent(event) => *event == BareEvent::WorkflowRun,
            Trigger::BareEvents(events) => events.contains(&BareEvent::WorkflowRun),
            Trigger::Events(events) => !matches!(events.workflow_run, OptionalBody::Missing),
        }
    }

    /// Whether this workflow is triggered by `workflow_call`, i.e. whether it's reusable or not.
    pub(crate) fn has_workflow_call(&self) -> bool {
        match &self.on {
            Trigger::BareEvent(event) => *event == BareEvent::WorkflowCall,
            Trigger::BareEvents(events) => events.contains(&BareEvent::WorkflowCall),
            Trigger::Events(events) => !matches!(events.workflow_call, OptionalBody::Missing),
        }
    }

    /// Whether this workflow is triggered by exactly one event.
    pub(crate) fn has_single_trigger(&self) -> bool {
        match &self.on {
            Trigger::BareEvent(_) => true,
            Trigger::BareEvents(events) => events.len() == 1,
            Trigger::Events(events) => events.count() == 1,
        }
    }

    /// Whether this workflow is *only* a reusable workflow, i.e. it's triggered by a
    /// `workflow_call` event and nothing else.
    pub(crate) fn is_reusable_only(&self) -> bool {
        self.has_workflow_call() && self.has_single_trigger()
    }

    /// Returns this workflow's [`SymbolicLocation`].
    ///
    /// NOTE: This is intentionally implemented directly on the `Workflow` type
    /// rather than through the [`Locatable`] trait, since introducing
    /// this through [`Locatable`] would require a split lifetime between
    /// `'self` and `'doc` for just this and [`Action`], i.e. the owning
    /// container types rather than the borrowing subtypes.
    pub fn location(&self) -> SymbolicLocation<'_> {
        SymbolicLocation {
            key: &self.key,
            annotation: "this workflow".into(),
            link: None,
            route: Default::default(),
            feature_kind: SymbolicFeature::Normal,
            kind: Default::default(),
        }
    }
}

/// Represents a single "normal" GitHub Actions job.
#[derive(Clone)]
pub(crate) struct NormalJob<'doc> {
    /// The job's unique ID (i.e., its key in the workflow's `jobs:` block).
    id: &'doc str,
    /// The underlying job.
    inner: &'doc job::NormalJob,
    /// The job's parent [`Workflow`].
    parent: &'doc Workflow,
}

impl<'doc> NormalJob<'doc> {
    pub(crate) fn new(id: &'doc str, inner: &'doc job::NormalJob, parent: &'doc Workflow) -> Self {
        Self { id, inner, parent }
    }

    /// This job's matrix, if it has one.
    pub(crate) fn matrix(&self) -> Option<Matrix<'doc>> {
        Matrix::new(self)
    }

    /// An iterator of this job's constituent [`Step`]s.
    pub(crate) fn steps(&self) -> Steps<'doc> {
        Steps::new(self)
    }

    /// Returns whether this job has the `id-token: write` permission.
    pub(crate) fn has_id_token(&self) -> bool {
        // Figure out which permissions we need to be looking at.
        // We look at the job's own permissions unless they indicate
        // that they're the default, in which case we know the effective
        // permissions are the parent workflow's.
        let effective_permissions = match self.permissions {
            common::Permissions::Base(common::BasePermission::Default) => &self.parent.permissions,
            _ => &self.permissions,
        };

        match effective_permissions {
            common::Permissions::Base(common::BasePermission::WriteAll) => true,
            common::Permissions::Explicit(explicit) => explicit
                .get("id-token")
                .is_some_and(|perm| matches!(perm, common::Permission::Write)),
            _ => false,
        }
    }

    /// Perform feats of heroism to figure of what this job's runner's
    /// default shell is.
    ///
    /// Returns `None` if the job is not a normal job, or if the runner
    /// environment is indeterminate (e.g. controlled by an expression).
    pub(crate) fn runner_default_shell(&self) -> Option<&'static str> {
        match &self.runs_on {
            // The entire runs-on is an expression, so there's nothing we can do.
            LoE::Expr(_) => None,
            LoE::Literal(job::RunsOn::Group { group: _, labels })
            | LoE::Literal(job::RunsOn::Target(labels)) => {
                for label in labels {
                    match label.as_str() {
                        // Default self-hosted routing labels.
                        "linux" | "macOS" => return Some("bash"),
                        "windows" => return Some("pwsh"),
                        // Standard GitHub-hosted runners, e.g. `ubuntu-latest`.
                        // We check only the prefix here so that we don't have to keep track
                        // of every possible variation of these runners.
                        l if l.contains("ubuntu-") || l.contains("macos") => return Some("bash"),
                        l if l.contains("windows-") => return Some("pwsh"),
                        _ => continue,
                    }
                }

                None
            }
        }
    }

    /// Returns an iterator over this job's conditions, including all
    /// step-level conditions.
    ///
    /// Each [`common::If`] is paired with a [`SymbolicLocation`]
    /// for its *parent*, i.e. a job or step.
    pub(crate) fn conditions(
        &self,
    ) -> impl Iterator<Item = (&'doc common::If, SymbolicLocation<'doc>)> {
        self.r#if.iter().map(|cond| (cond, self.location())).chain(
            self.steps()
                .filter_map(|step| step.r#if().map(|cond| (cond, step.location()))),
        )
    }
}

impl<'a, 'doc> AsDocument<'a, 'doc> for NormalJob<'doc> {
    fn as_document(&'a self) -> &'doc yamlpath::Document {
        self.parent.as_document()
    }
}

impl<'doc> JobCommon<'doc> for NormalJob<'doc> {
    fn id(&self) -> &'doc str {
        self.id
    }

    fn name(&self) -> Option<&'doc str> {
        self.inner.name.as_deref()
    }

    fn parent(&self) -> &'doc Workflow {
        self.parent
    }
}

impl<'doc> std::ops::Deref for NormalJob<'doc> {
    type Target = &'doc job::NormalJob;

    fn deref(&self) -> &Self::Target {
        &self.inner
    }
}

/// Represents a reusable workflow call job.
#[derive(Clone)]
pub(crate) struct ReusableWorkflowCallJob<'doc> {
    /// The job's unique ID (i.e., its key in the workflow's `jobs:` block).
    id: &'doc str,
    /// The underlying job.
    inner: &'doc job::ReusableWorkflowCallJob,
    /// The job's parent [`Workflow`].
    parent: &'doc Workflow,
}

impl<'doc> ReusableWorkflowCallJob<'doc> {
    pub(crate) fn new(
        id: &'doc str,
        inner: &'doc job::ReusableWorkflowCallJob,
        parent: &'doc Workflow,
    ) -> Self {
        Self { id, inner, parent }
    }
}

impl<'a, 'doc> AsDocument<'a, 'doc> for ReusableWorkflowCallJob<'doc> {
    fn as_document(&'a self) -> &'doc yamlpath::Document {
        self.parent.as_document()
    }
}

impl<'doc> JobCommon<'doc> for ReusableWorkflowCallJob<'doc> {
    fn id(&self) -> &'doc str {
        self.id
    }

    fn name(&self) -> Option<&'doc str> {
        self.inner.name.as_deref()
    }

    fn parent(&self) -> &'doc Workflow {
        self.parent
    }
}

impl<'doc> std::ops::Deref for ReusableWorkflowCallJob<'doc> {
    type Target = &'doc job::ReusableWorkflowCallJob;

    fn deref(&self) -> &Self::Target {
        &self.inner
    }
}

/// Common behavior across both normal and reusable jobs.
pub(crate) trait JobCommon<'doc>: Locatable<'doc> {
    /// The job's unique ID (i.e., its key in the workflow's `jobs:` block).
    fn id(&self) -> &'doc str;

    // The job's name, if it has one.
    fn name(&self) -> Option<&'doc str>;

    /// The job's parent [`Workflow`].
    fn parent(&self) -> &'doc Workflow;
}

impl<'doc, T: JobCommon<'doc>> Locatable<'doc> for T {
    /// Returns this job's [`SymbolicLocation`].
    fn location(&self) -> SymbolicLocation<'doc> {
        self.parent()
            .location()
            .annotated("this job")
            .with_keys(["jobs".into(), self.id().into()])
    }

    fn location_with_grip(&self) -> SymbolicLocation<'doc> {
        if self.name().is_some() {
            self.location().with_keys(["name".into()])
        } else {
            self.parent()
                .location()
                .annotated("this job")
                .with_keys(["jobs".into(), self.id().into()])
                .key_only()
        }
    }
}

/// Represents a single GitHub Actions job.
#[derive(Clone)]
pub(crate) enum Job<'doc> {
    NormalJob(NormalJob<'doc>),
    ReusableWorkflowCallJob(ReusableWorkflowCallJob<'doc>),
}

impl<'doc> Job<'doc> {
    fn new(id: &'doc str, inner: &'doc workflow::Job, parent: &'doc Workflow) -> Self {
        match inner {
            workflow::Job::NormalJob(normal) => Job::NormalJob(NormalJob::new(id, normal, parent)),
            workflow::Job::ReusableWorkflowCallJob(reusable) => {
                Job::ReusableWorkflowCallJob(ReusableWorkflowCallJob::new(id, reusable, parent))
            }
        }
    }
}

/// An iterable container for jobs within a [`Workflow`].
///
/// Jobs whose `if:` condition is statically known to be false are skipped, since such jobs
/// cannot execute and therefore can't violate any audits.
pub(crate) struct Jobs<'doc> {
    parent: &'doc Workflow,
    inner: indexmap::map::Iter<'doc, String, workflow::Job>,
}

impl<'doc> Jobs<'doc> {
    fn new(workflow: &'doc Workflow) -> Self {
        Self {
            parent: workflow,
            inner: workflow.jobs.iter(),
        }
    }
}

impl<'doc> Iterator for Jobs<'doc> {
    type Item = Job<'doc>;

    fn next(&mut self) -> Option<Self::Item> {
        for (id, job) in self.inner.by_ref() {
            let cond = match job {
                workflow::Job::NormalJob(normal) => normal.r#if.as_ref(),
                workflow::Job::ReusableWorkflowCallJob(reusable) => reusable.r#if.as_ref(),
            };

            if let Some(cond) = cond
                && crate::models::if_is_statically_false(cond)
            {
                continue;
            }

            return Some(Job::new(id, job, self.parent));
        }

        None
    }
}

/// Represents a single step in a normal workflow job.
///
/// [`Step`]s are produced by the [`Steps`] iterator, meaning that they present
/// a "flattened" representation as documented there.
///
/// This type implements [`std::ops::Deref`] for [`workflow::job::Step`], which
/// provides access to the step's actual fields.
#[derive(Clone)]
pub(crate) struct Step<'doc> {
    /// The step's index within its parent job's `steps:` block.
    steps_index: usize,
    /// The step's index within its `parallel:` block, if it's within one.
    parallel_index: Option<usize>,
    /// The inner step model.
    inner: StepInner<'doc>,
    /// The parent [`Job`].
    pub(crate) parent: NormalJob<'doc>,
}

impl<'doc> std::ops::Deref for Step<'doc> {
    type Target = StepInner<'doc>;

    fn deref(&self) -> &Self::Target {
        &self.inner
    }
}

impl<'doc> Locatable<'doc> for Step<'doc> {
    /// This step's [`SymbolicLocation`].
    fn location(&self) -> SymbolicLocation<'doc> {
        if let Some(parallel_index) = self.parallel_index {
            self.job().location().with_keys([
                "steps".into(),
                self.steps_index.into(),
                "parallel".into(),
                parallel_index.into(),
            ])
        } else {
            self.job()
                .location()
                .with_keys(["steps".into(), self.steps_index.into()])
        }
        .annotated("this step")
    }

    fn location_with_grip(&self) -> SymbolicLocation<'doc> {
        if self.name().is_some() {
            self.location().with_keys(["name".into()])
        } else if self.id().is_some() {
            self.location().with_keys(["id".into()])
        } else {
            self.location()
        }
    }
}

impl HasInputs for Step<'_> {
    fn get_input(&self, name: &str) -> Option<Capability> {
        self.workflow().get_input(name)
    }
}

impl<'doc> StepCommon<'doc> for Step<'doc> {
    fn ord(&self) -> impl Ord {
        // Observe that the ordering of steps takes their parallel nesting
        // into account, if present.
        (self.steps_index, self.parallel_index)
    }

    fn env_is_static(&self, ctx: &context::Context) -> bool {
        utils::env_is_static(ctx, &[self.env(), &self.job().env, &self.workflow().env])
    }

    fn uses(&self) -> Option<&'doc common::Uses> {
        let StepInner::Uses(job::UsesStep { uses, .. }) = &self.inner else {
            return None;
        };

        Some(uses)
    }

    fn matrix(&self) -> Option<Matrix<'doc>> {
        self.job().matrix()
    }

    fn body(&self) -> Option<StepBodyCommon<'doc>> {
        match &self.inner {
            StepInner::Uses(job::UsesStep { uses, with, .. }) => {
                Some(StepBodyCommon::Uses { uses, with })
            }
            StepInner::Run(job::RunStep {
                run,
                working_directory,
                shell,
                ..
            }) => Some(StepBodyCommon::Run {
                run,
                _working_directory: working_directory.as_deref(),
                _shell: shell.as_ref(),
            }),
        }
    }

    fn document(&self) -> &'doc yamlpath::Document {
        self.workflow().as_document()
    }

    fn shell(&self) -> Option<(&str, SymbolicLocation<'doc>)> {
        // For workflow steps, we can use the existing shell() method
        self.shell()
    }
}

impl<'doc> Step<'doc> {
    fn new(
        steps_index: usize,
        parallel_index: Option<usize>,
        inner: StepInner<'doc>,
        parent: NormalJob<'doc>,
    ) -> Self {
        Self {
            steps_index,
            parallel_index,
            inner,
            parent,
        }
    }

    pub(crate) fn name(&self) -> Option<&'doc str> {
        match self.inner {
            StepInner::Uses(uses) => uses.shared.name.as_deref(),
            StepInner::Run(run) => run.shared.name.as_deref(),
        }
    }

    pub(crate) fn id(&self) -> Option<&'doc str> {
        match self.inner {
            StepInner::Uses(uses) => uses.shared.id.as_deref(),
            StepInner::Run(run) => run.shared.id.as_deref(),
        }
    }

    pub(crate) fn r#if(&self) -> Option<&'doc common::If> {
        match self.inner {
            StepInner::Uses(uses) => uses.shared.r#if.as_ref(),
            StepInner::Run(run) => run.shared.r#if.as_ref(),
        }
    }

    pub(crate) fn env(&self) -> &'doc LoE<common::Env> {
        match self.inner {
            StepInner::Uses(uses) => &uses.shared.env,
            StepInner::Run(run) => &run.shared.env,
        }
    }

    /// Returns this step's parent [`NormalJob`].
    pub(crate) fn job(&self) -> &NormalJob<'doc> {
        &self.parent
    }

    /// Returns this step's (grand)parent [`Workflow`].
    pub(crate) fn workflow(&self) -> &'doc Workflow {
        self.parent.parent()
    }

    /// Returns the the shell used by this step, or `None`
    /// if the shell can't be statically inferred.
    ///
    /// Invariant: panics if the step is not a `run:` step.
    pub(crate) fn shell(&self) -> Option<(&str, SymbolicLocation<'doc>)> {
        let StepInner::Run(job::RunStep { shell, .. }) = &self.inner else {
            panic!("API misuse: can't call shell() on a uses: step")
        };

        // The steps's own `shell:` takes precedence, followed by the
        // job's default, followed by the entire workflow's default,
        // followed by the runner's default.
        // If any of these is an expression, we can't infer the shell
        // statically, so we terminate early with `None`.
        let shell = match shell {
            Some(LoE::Literal(shell)) => Some((
                shell.as_str(),
                self.location()
                    .with_keys(["shell".into()])
                    .annotated("shell defined here"),
            )),
            Some(LoE::Expr(_)) => return None,
            None => match self
                .job()
                .defaults
                .as_ref()
                .and_then(|d| d.run.as_ref().and_then(|r| r.shell.as_ref()))
            {
                Some(LoE::Literal(shell)) => Some((
                    shell.as_str(),
                    self.job()
                        .location()
                        .with_keys(["defaults".into(), "run".into(), "shell".into()])
                        .annotated("job default shell defined here"),
                )),
                Some(LoE::Expr(_)) => return None,
                None => match self
                    .workflow()
                    .defaults
                    .as_ref()
                    .and_then(|d| d.run.as_ref().and_then(|r| r.shell.as_ref()))
                {
                    Some(LoE::Literal(shell)) => Some((
                        shell.as_str(),
                        self.workflow()
                            .location()
                            .with_keys(["defaults".into(), "run".into(), "shell".into()])
                            .annotated("workflow default shell defined here"),
                    )),
                    Some(LoE::Expr(_)) => return None,
                    None => None,
                },
            },
        };

        shell.or_else(|| {
            self.parent.runner_default_shell().map(|shell| {
                (
                    shell,
                    self.job()
                        .location()
                        .with_keys(["runs-on".into()])
                        .annotated("shell implied by runner"),
                )
            })
        })
    }
}

/// The subset of [`job::Step`] variants that get expressed through
/// the [`Step`] API. This is used to reduce the number of unreachable
/// typestates we need to match against.
#[derive(Clone)]
pub(crate) enum StepInner<'doc> {
    Uses(&'doc job::UsesStep),
    Run(&'doc job::RunStep),
}

/// An iterable container for steps within a [`Job`].
///
/// This iterator flattens steps that are nested under a `parallel:` pseudo-step.
/// For example, this job:
///
/// ```yaml
/// steps:
///   - run: echo a
///   - parallel:
///     - run: echo b
///     - run: echo c
/// ```
///
/// ...becomes a flat iterator over the three `run:` blocks, with the `parallel:`
/// block itself not directly surfaced by iteration.
///
/// Steps whose `if:` condition is statically known to be false are skipped,
/// since such steps cannot execute and therefore can't violate any audits.
pub(crate) struct Steps<'doc> {
    /// Iterator over the job's top-level steps.
    outer: std::iter::Enumerate<std::slice::Iter<'doc, job::Step>>,
    /// When iterating a `parallel:` pseudo-step, holds its `steps` index along
    /// with an iterator over its nested steps. `None` otherwise.
    parallel: Option<(
        usize,
        std::iter::Enumerate<std::slice::Iter<'doc, job::ParallelStep>>,
    )>,
    parent: NormalJob<'doc>,
}

impl<'doc> Steps<'doc> {
    /// Create a new [`Steps`].
    fn new(job: &NormalJob<'doc>) -> Self {
        Self {
            outer: job.steps.iter().enumerate(),
            parallel: None,
            parent: job.clone(),
        }
    }

    /// Yield the next flattened `(steps_index, parallel_index, step)`, expanding
    /// `parallel:` pseudo-steps inline. Unlike [`Iterator::next`], this does not
    /// filter out statically-disabled steps.
    fn next_raw(&mut self) -> Option<(usize, Option<usize>, StepInner<'doc>)> {
        loop {
            // Drain any in-progress `parallel:` block before advancing the
            // outer iterator.
            if let Some((step_idx, par_iter)) = self.parallel.as_mut() {
                if let Some((par_idx, step)) = par_iter.next() {
                    let step = match step {
                        job::ParallelStep::Uses(uses) => StepInner::Uses(uses),
                        job::ParallelStep::Run(run) => StepInner::Run(run),
                    };

                    return Some((*step_idx, Some(par_idx), step));
                }
                self.parallel = None;
            }

            let (step_idx, step) = self.outer.next()?;
            match &step {
                job::Step::Uses(uses) => return Some((step_idx, None, StepInner::Uses(uses))),
                job::Step::Run(run) => return Some((step_idx, None, StepInner::Run(run))),
                job::Step::Wait { .. } | job::Step::WaitAll { .. } | job::Step::Cancel { .. } => {
                    continue;
                }
                job::Step::Parallel {
                    parallel: parallel_steps,
                    ..
                } => {
                    // TODO: Remove once stabilized.
                    warn_once!(
                        "one or more inputs contains parallel steps; zizmor's support \
                        for these is currently experimental. see \
                        https://docs.zizmor.sh/usage/#parallel-step for details"
                    );

                    self.parallel = Some((step_idx, parallel_steps.iter().enumerate()));
                }
            }
        }
    }
}

impl<'doc> Iterator for Steps<'doc> {
    type Item = Step<'doc>;

    fn next(&mut self) -> Option<Self::Item> {
        while let Some((step_idx, par_idx, step)) = self.next_raw() {
            // Skip steps whose `if:` is statically known to be false.
            let r#if = match step {
                StepInner::Uses(uses) => uses.shared.r#if.as_ref(),
                StepInner::Run(run) => run.shared.r#if.as_ref(),
            };

            if let Some(cond) = r#if
                && crate::models::if_is_statically_false(cond)
            {
                continue;
            }
            return Some(Step::new(step_idx, par_idx, step, self.parent.clone()));
        }
        None
    }
}

#[cfg(test)]
mod tests {
    use crate::models::{
        inputs::{Capability, HasInputs as _},
        workflow::Workflow,
    };

    #[test]
    fn test_workflow_has_inputs() -> anyhow::Result<()> {
        let workflow = r#"
name: Test Workflow
on:
  workflow_dispatch:
    inputs:
      foo:
        type: string
        required: true
      bar:
        type: boolean
        required: false
  workflow_call:
    inputs:
      foo:
        type: number
        required: true
      bar:
        type: boolean
        required: false

jobs:
  test_job:
    runs-on: ubuntu-latest
    steps:
      - run: true
"#;

        let workflow = Workflow::from_string(
            workflow.into(),
            crate::InputKey::local("fakegroup".into(), "dummy", None, None),
        )?;

        // `foo` unifies in favor of the more permissive capability,
        // which is `Capability::Arbitrary` from the `string` input type
        // under `workflow_dispatch`.
        let foo_cap = workflow.get_input("foo").unwrap();
        assert_eq!(foo_cap, Capability::Arbitrary);

        // `bar` unifies to `Capability::Fixed` since both
        // `workflow_dispatch` and `workflow_call` define it as a boolean.
        let bar_cap = workflow.get_input("bar").unwrap();
        assert_eq!(bar_cap, Capability::Fixed);

        Ok(())
    }
}
