//! Workflow / run config loading helpers.
//!
//! Helpers for loading workflow-local settings and resolving runtime goal /
//! graph paths. Runtime types that used to be re-exported from here live
//! under `fabro_types::settings::run` now.

#![expect(
    clippy::disallowed_methods,
    reason = "sync run-config loading helpers; not on a Tokio path"
)]

use std::path::{Path, PathBuf};

use fabro_types::settings::run::{ResolvedGoalSource, ResolvedRunGoal, RunGoal, RunNamespace};
use fabro_types::settings::{InterpString, ResolveCtx, ResolveError};

use crate::load::{load_settings_path, resolve_goal_file_path};
use crate::parse::{SettingsSource, validate_settings_source};
use crate::{Result, RunGoalLayer, RunLayer, SettingsLayer};

/// Load and parse a run config from a TOML file.
///
/// Goes through [`load_settings_path`] so that relative `run.goal.file`
/// paths are anchored at the directory of `path` at load time.
pub(crate) fn load_run_config(path: &Path) -> Result<SettingsLayer> {
    load_settings_path(path, SettingsSource::Workflow)
}

/// Parse a settings TOML source string and extract its `[run]` layer.
///
/// Goes through the strict `SettingsLayer` `FromStr` impl, so valid settings
/// domains (`_version`, `[workflow]`, `[run]`, `[server]`, etc.) parse cleanly,
/// while unknown top-level domains or unknown nested keys under a known domain
/// trip `deny_unknown_fields`. Bundled
/// `workflow.toml` configurations parse through this path so stale
/// schema (e.g. `[server.integrations.github.permissions]` after the
/// move to `[run.integrations.github.permissions]`) is rejected up front
/// rather than silently dropped by `RunLayer::try_from(toml::Value)`.
///
/// Exists as a public helper because `SettingsLayer` itself is
/// `pub(crate)`, so external crates cannot replicate this two-line dance
/// inline.
pub fn parse_run_layer_from_settings_toml(source: &str) -> Result<RunLayer> {
    let layer = source
        .parse::<SettingsLayer>()
        .map_err(|err| crate::Error::parse("Failed to parse run config TOML", err))?;
    validate_settings_source(&layer, SettingsSource::DirectRun)
        .map_err(|err| crate::Error::parse("Failed to parse run config TOML", err))?;
    Ok(layer.run.unwrap_or_default())
}

/// Resolve a graph path relative to a workflow.toml.
#[must_use]
pub fn resolve_graph_path(workflow_toml: &Path, graph_relative: &str) -> PathBuf {
    workflow_toml
        .parent()
        .unwrap_or_else(|| Path::new("."))
        .join(graph_relative)
}

#[derive(Debug)]
pub enum ResolveRunGoalError {
    Interpolation {
        source: ResolveError,
    },
    Io {
        path:   PathBuf,
        source: std::io::Error,
    },
}

impl std::fmt::Display for ResolveRunGoalError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Interpolation { source } => {
                write!(f, "run.goal.file interpolation failed: {source}")
            }
            Self::Io { path, source } => {
                write!(f, "failed to read goal file {}: {source}", path.display())
            }
        }
    }
}

impl std::error::Error for ResolveRunGoalError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::Interpolation { source } => Some(source),
            Self::Io { source, .. } => Some(source),
        }
    }
}

pub fn resolve_run_goal_from_layer(
    run: &RunLayer,
    base_dir: &Path,
) -> std::result::Result<Option<ResolvedRunGoal>, ResolveRunGoalError> {
    let Some(goal) = run.goal.as_ref() else {
        return Ok(None);
    };

    resolve_layer_goal(goal, base_dir).map(Some)
}

pub fn resolve_run_goal_from_namespace(
    run: &RunNamespace,
    base_dir: &Path,
) -> std::result::Result<Option<ResolvedRunGoal>, ResolveRunGoalError> {
    let Some(goal) = run.goal.as_ref() else {
        return Ok(None);
    };

    resolve_goal(goal, base_dir).map(Some)
}

fn resolve_goal_file(
    file: &InterpString,
    base_dir: &Path,
) -> std::result::Result<ResolvedRunGoal, ResolveRunGoalError> {
    // `{{ vars.* }}` is substituted server-side at run creation, so the path is
    // literal by this point; anything left unresolved fails closed.
    let resolved = file
        .resolve_with(&mut ResolveCtx::new())
        .map_err(|source| ResolveRunGoalError::Interpolation { source })?;
    let path = resolve_goal_file_path(&resolved, base_dir);
    let text = std::fs::read_to_string(&path).map_err(|source| ResolveRunGoalError::Io {
        path: path.clone(),
        source,
    })?;
    Ok(ResolvedRunGoal {
        text,
        source: ResolvedGoalSource::File { path },
    })
}

#[expect(
    clippy::disallowed_methods,
    reason = "goal text intentionally passes through in source form; goals become importable \
              templates in the interpolation unification (Phase 3)"
)]
fn resolve_layer_goal(
    goal: &RunGoalLayer,
    base_dir: &Path,
) -> std::result::Result<ResolvedRunGoal, ResolveRunGoalError> {
    match goal {
        RunGoalLayer::Inline(text) => Ok(ResolvedRunGoal {
            text:   text.as_source(),
            source: ResolvedGoalSource::Inline,
        }),
        RunGoalLayer::File { file } => resolve_goal_file(file, base_dir),
    }
}

#[expect(
    clippy::disallowed_methods,
    reason = "goal text intentionally passes through in source form; goals become importable \
              templates in the interpolation unification (Phase 3)"
)]
fn resolve_goal(
    goal: &RunGoal,
    base_dir: &Path,
) -> std::result::Result<ResolvedRunGoal, ResolveRunGoalError> {
    match goal {
        RunGoal::Inline(text) => Ok(ResolvedRunGoal {
            text:   text.as_source(),
            source: ResolvedGoalSource::Inline,
        }),
        RunGoal::File(file) => resolve_goal_file(file, base_dir),
    }
}

#[cfg(test)]
#[expect(
    clippy::disallowed_methods,
    reason = "tests assert the raw template source"
)]
mod tests {
    use fabro_types::settings::run::RunGoal;

    use super::*;
    use crate::RunGoalLayer;

    #[test]
    fn load_run_config_rewrites_relative_goal_file_path() {
        let tmp = tempfile::tempdir().unwrap();
        let workflow_dir = tmp.path().join("fabro").join("workflows").join("demo");
        std::fs::create_dir_all(&workflow_dir).unwrap();
        let workflow_toml = workflow_dir.join("workflow.toml");
        std::fs::write(
            &workflow_toml,
            r#"_version = 1

[run.goal]
file = "prompts/goal.md"
"#,
        )
        .unwrap();

        let config = load_run_config(&workflow_toml).unwrap();
        let Some(RunGoalLayer::File { file }) =
            config.run.as_ref().and_then(|run| run.goal.as_ref())
        else {
            panic!("expected file variant");
        };
        let expected = workflow_dir.join("prompts").join("goal.md");
        assert_eq!(file.as_source(), expected.to_string_lossy());
    }

    #[test]
    fn load_run_config_leaves_absolute_goal_file_untouched() {
        let tmp = tempfile::tempdir().unwrap();
        let workflow_toml = tmp.path().join("workflow.toml");
        std::fs::write(
            &workflow_toml,
            r#"_version = 1

[run.goal]
file = "/etc/fabro/goal.md"
"#,
        )
        .unwrap();

        let config = load_run_config(&workflow_toml).unwrap();
        let Some(RunGoalLayer::File { file }) =
            config.run.as_ref().and_then(|run| run.goal.as_ref())
        else {
            panic!("expected file variant");
        };
        assert_eq!(file.as_source(), "/etc/fabro/goal.md");
    }

    #[test]
    fn resolve_run_goal_from_namespace_reads_file_goal() {
        let tmp = tempfile::tempdir().unwrap();
        let goal_path = tmp.path().join("goal.md");
        std::fs::write(&goal_path, "ship from namespace").unwrap();

        let resolved = resolve_run_goal_from_namespace(
            &RunNamespace {
                goal: Some(RunGoal::File(InterpString::parse(
                    &goal_path.display().to_string(),
                ))),
                ..RunNamespace::default()
            },
            tmp.path(),
        )
        .unwrap()
        .expect("goal should resolve");

        assert_eq!(resolved.text, "ship from namespace");
        assert_eq!(resolved.source, ResolvedGoalSource::File {
            path: goal_path,
        });
    }
}
