use std::fmt;

use fabro_model::catalog::LegacyModelError;

use crate::SettingsLayer;

const CURRENT_VERSION: u32 = 1;

const ALLOWED_TOP_LEVEL_KEYS: &[&str] = &[
    "_version",
    "project",
    "workflow",
    "environments",
    "run",
    "cli",
    "server",
    "llm",
];

/// Legacy `[llm]` keys that pre-date the settings-driven catalog plan and
/// should still produce the migration hint for `[run.model]` rather than be
/// silently accepted by the new `[llm]` schema.
const LEGACY_LLM_KEYS: &[&str] = &[
    "provider",
    "model",
    "temperature",
    "max_tokens",
    "fallbacks",
    "fallback",
];

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
    Toml(String),
    LlmCatalog(LegacyModelError),
    Version(VersionError),
    UnknownTopLevelKey {
        key:  String,
        hint: Option<String>,
    },
    ServerManagedEnvironmentCwd {
        path:   String,
        source: SettingsSource,
    },
}

impl fmt::Display for ParseError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Toml(msg) => write!(f, "settings file is not valid TOML: {msg}"),
            Self::LlmCatalog(err) => fmt::Display::fmt(err, f),
            Self::Version(err) => fmt::Display::fmt(err, f),
            Self::UnknownTopLevelKey { key, hint } => {
                if let Some(hint) = hint {
                    write!(f, "unknown top-level settings key `{key}`: {hint}")
                } else {
                    write!(
                        f,
                        "unknown top-level settings key `{key}`: expected one of `_version`, `project`, `workflow`, `environments`, `run`, `cli`, `server`, `llm`"
                    )
                }
            }
            Self::ServerManagedEnvironmentCwd { path, source } => write!(
                f,
                "`{path}` is server-managed and cannot be set in {source} settings; configure cwd on a server-managed environment instead."
            ),
        }
    }
}

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

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VersionError {
    LegacyVersionKey,
    UnsupportedHigherVersion { found: u32 },
}

impl fmt::Display for VersionError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::LegacyVersionKey => f.write_str(
                "settings files must use `_version` instead of `version`. Rename the key and try again.",
            ),
            Self::UnsupportedHigherVersion { found } => write!(
                f,
                "settings schema version {found} is newer than this build supports (current: {CURRENT_VERSION}). Upgrade Fabro to read this file."
            ),
        }
    }
}

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

pub(crate) fn parse_settings(input: &str) -> Result<SettingsLayer, ParseError> {
    let raw: toml::Value = toml::from_str(input).map_err(|e| ParseError::Toml(e.to_string()))?;
    validate_version(&raw).map_err(ParseError::Version)?;

    if let Some(table) = raw.as_table() {
        for key in table.keys() {
            if !ALLOWED_TOP_LEVEL_KEYS.contains(&key.as_str()) {
                return Err(ParseError::UnknownTopLevelKey {
                    key:  key.clone(),
                    hint: rename_hint(key),
                });
            }
        }

        // The settings-driven catalog plan re-introduced `[llm]` for provider
        // and model rows. Old top-level `[llm]` keys (`provider`, `model`,
        // ...) are forbidden and must continue to surface the migration hint
        // for `[run.model]` rather than be silently dropped or generic-error.
        if let Some(llm_table) = table.get("llm").and_then(toml::Value::as_table) {
            for legacy_key in LEGACY_LLM_KEYS {
                if llm_table.contains_key(*legacy_key) {
                    return Err(ParseError::UnknownTopLevelKey {
                        key:  format!("llm.{legacy_key}"),
                        hint: rename_hint("llm"),
                    });
                }
            }
        }
    }

    let mut layer = raw
        .try_into::<SettingsLayer>()
        .map_err(|e| ParseError::Toml(e.to_string()))?;
    if let Some(llm) = layer.llm.as_mut() {
        llm.normalize_legacy_models()
            .map_err(ParseError::LlmCatalog)?;
    }
    Ok(layer)
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsSource {
    ActiveSettings,
    Project,
    Workflow,
    DirectRun,
    User,
}

impl SettingsSource {
    /// `ActiveSettings` is the aggregated server-side settings file. Other
    /// sources are client-provided leaf configs and must not be rewritten while
    /// building or preparing a run manifest.
    #[must_use]
    pub(crate) fn runs_settings_migrations(self) -> bool {
        matches!(self, Self::ActiveSettings)
    }

    #[must_use]
    fn allows_environment_cwd(self) -> bool {
        matches!(self, Self::ActiveSettings)
    }
}

impl fmt::Display for SettingsSource {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let label = match self {
            Self::ActiveSettings => "active server",
            Self::Project => "project",
            Self::Workflow => "workflow",
            Self::DirectRun => "direct-run",
            Self::User => "user",
        };
        f.write_str(label)
    }
}

pub fn validate_settings_source(
    layer: &SettingsLayer,
    source: SettingsSource,
) -> Result<(), ParseError> {
    if !source.allows_environment_cwd() {
        for (id, environment) in layer.environments.iter() {
            if environment.cwd.is_some() {
                return Err(ParseError::ServerManagedEnvironmentCwd {
                    path: format!("environments.{id}.cwd"),
                    source,
                });
            }
        }
    }
    Ok(())
}

fn validate_version(raw: &toml::Value) -> Result<(), VersionError> {
    if let Some(table) = raw.as_table() {
        if table.contains_key("version") {
            return Err(VersionError::LegacyVersionKey);
        }
        if let Some(value) = table.get("_version").and_then(toml::Value::as_integer) {
            let found = u32::try_from(value).unwrap_or(u32::MAX);
            if found > CURRENT_VERSION {
                return Err(VersionError::UnsupportedHigherVersion { found });
            }
        }
    }
    Ok(())
}

fn rename_hint(key: &str) -> Option<String> {
    let target = match key {
        "version" => "rename to `_version`",
        "goal" | "goal_file" | "work_dir" | "directory" => "move to `[run]`",
        "graph" => "move to `[workflow]`",
        "labels" => "move to `[run.metadata]`",
        "llm" => "rename to `[run.model]`",
        "vars" => "rename to `[run.inputs]`",
        "setup" => "rename to `[run.prepare]`",
        "sandbox" => "rename to `[run.environment]` and `[environments.<slug>]`",
        "checkpoint" => "move under `[run.checkpoint]`",
        "pull_request" => "move under `[run.pull_request]`",
        "artifacts" => "move under `[run.artifacts]`",
        "hooks" => "move under `[[run.hooks]]`",
        "mcp_servers" => "move under `[run.agent.mcps.<name>]` or `[cli.exec.agent.mcps.<name>]`",
        "exec" => "rename to `[cli.exec]`",
        "api" => "rename to `[server.api]`",
        "web" => "rename to `[server.web]`",
        "artifact_storage" => "rename to `[server.artifacts]`",
        "storage_dir" | "data_dir" => "rename to `[server.storage] root`",
        "max_concurrent_runs" => "rename to `[server.scheduler]` field",
        "fabro" => "rename to `[project]`; project workflows now live under `.fabro/workflows`",
        "git" => "split into `[run.git]` (local git behavior) and `[server.integrations.github]`",
        "github" => {
            "split into `[server.integrations.github]` (App identity/auth) and \
             `[run.integrations.github.permissions]` (sandbox token scopes)"
        }
        "slack" => "move under `[server.integrations.slack]`",
        "log" => "rename to `[server.logging]` or `[cli.logging]` depending on owner",
        "prevent_idle_sleep" => "rename to `[cli.exec] prevent_idle_sleep`",
        "verbose" => "rename to `[cli.output] verbosity`",
        "upgrade_check" => "rename to `[cli.updates] check`",
        "dry_run" => "rename to `[run.execution] mode = \"dry_run\"`",
        "auto_approve" => "rename to `[run.execution] approval = \"auto\"`",
        _ => return None,
    };
    Some(target.to_owned())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_empty_file() {
        let file = "".parse::<SettingsLayer>().unwrap();
        assert_eq!(file, SettingsLayer::default());
    }

    #[test]
    fn parses_minimal_valid_file() {
        let file = "_version = 1\n".parse::<SettingsLayer>().unwrap();
        assert_eq!(file.version, Some(1));
    }

    #[test]
    fn rejects_legacy_version_key_with_rename_hint() {
        let err = "version = 1".parse::<SettingsLayer>().unwrap_err();
        assert!(matches!(
            err,
            ParseError::Version(VersionError::LegacyVersionKey)
        ));
        assert!(err.to_string().contains("_version"));
    }

    #[test]
    fn rejects_unknown_top_level_key() {
        let err = "unknown_key = 1".parse::<SettingsLayer>().unwrap_err();
        assert!(matches!(err, ParseError::UnknownTopLevelKey { .. }));
    }

    #[test]
    fn rejects_legacy_features_section() {
        let namespace = "features";
        let err = format!("[{namespace}]\nremoved_flag = true\n")
            .parse::<SettingsLayer>()
            .unwrap_err();
        match err {
            ParseError::UnknownTopLevelKey { key, .. } => assert_eq!(key, "features"),
            other => panic!("expected UnknownTopLevelKey, got: {other:?}"),
        }
    }

    #[test]
    fn higher_version_rejected_with_upgrade_hint() {
        let err = "_version = 99".parse::<SettingsLayer>().unwrap_err();
        assert!(err.to_string().contains("Upgrade"));
    }

    #[test]
    fn accepts_new_llm_providers_subtree() {
        let parsed = "[llm.providers.moonshot]\nadapter = \"openai_compatible\"\n"
            .parse::<SettingsLayer>()
            .unwrap();
        assert!(parsed.llm.unwrap().providers.contains_key("moonshot"));
    }

    #[test]
    fn accepts_new_llm_models_subtree() {
        let parsed = "[llm.providers.moonshot.models.\"foo\"]\n"
            .parse::<SettingsLayer>()
            .unwrap();
        assert!(
            parsed
                .llm
                .unwrap()
                .providers
                .get("moonshot")
                .unwrap()
                .models
                .contains_key("foo")
        );
    }

    #[test]
    fn provider_scoped_model_rejects_redundant_provider_field() {
        let error = r#"
[llm.providers.openai.models."gpt-5.4"]
provider = "openai"
"#
        .parse::<SettingsLayer>()
        .unwrap_err();

        assert!(matches!(
            error,
            ParseError::LlmCatalog(LegacyModelError::ScopedModelDeclaresProvider {
                provider,
                model,
            }) if provider.as_str() == "openai" && model.as_str() == "gpt-5.4"
        ));
    }

    #[test]
    fn legacy_model_row_with_provider_normalizes_before_merge() {
        use crate::layers::Combine as _;

        let higher = r#"
[llm.models."gpt-5.4"]
provider = "openai"
display_name = "Configured display name"
"#
        .parse::<SettingsLayer>()
        .unwrap();
        let fallback = r#"
[llm.providers.openai.models."gpt-5.4"]
family = "gpt-5"
"#
        .parse::<SettingsLayer>()
        .unwrap();

        let merged = higher.combine(fallback);
        let llm = merged.llm.unwrap();
        assert!(llm.models.is_empty());
        let model = llm
            .providers
            .get("openai")
            .unwrap()
            .models
            .get("gpt-5.4")
            .unwrap();
        assert_eq!(
            model.display_name.as_deref(),
            Some("Configured display name")
        );
        assert_eq!(model.family.as_deref(), Some("gpt-5"));
    }

    #[test]
    fn provider_less_legacy_row_adopts_unique_builtin_offering() {
        let parsed = r#"
[llm.models.mercury]
display_name = "Configured Mercury"
"#
        .parse::<SettingsLayer>()
        .unwrap();
        let llm = parsed.llm.unwrap();

        assert!(llm.models.is_empty());
        assert!(
            llm.providers
                .get("inception")
                .unwrap()
                .models
                .contains_key("mercury-2")
        );
    }

    #[test]
    fn provider_less_legacy_row_rejects_ambiguous_builtin_offering() {
        let error = r#"
[llm.models."gpt-5.6-sol"]
display_name = "Ambiguous"
"#
        .parse::<SettingsLayer>()
        .unwrap_err();

        assert!(matches!(
            error,
            ParseError::LlmCatalog(LegacyModelError::AmbiguousModel {
                model,
                candidates,
            }) if model == "gpt-5.6-sol" && candidates.len() >= 2
        ));
    }

    #[test]
    fn same_source_legacy_and_provider_scoped_rows_conflict() {
        let error = r#"
[llm.providers.openai.models."gpt-5.4"]
display_name = "Canonical"

[llm.models."gpt-5.4"]
provider = "openai"
display_name = "Legacy"
"#
        .parse::<SettingsLayer>()
        .unwrap_err();

        assert!(matches!(
            error,
            ParseError::LlmCatalog(LegacyModelError::DuplicateModel {
                provider,
                model,
            }) if provider.as_str() == "openai" && model.as_str() == "gpt-5.4"
        ));
    }

    #[test]
    fn legacy_builtin_model_id_normalizes_with_explicit_provider() {
        let parsed = r#"
[llm.models."openai/gpt-5.6-sol"]
provider = "openrouter"
display_name = "Configured Sol"
"#
        .parse::<SettingsLayer>()
        .unwrap();
        let llm = parsed.llm.unwrap();

        assert!(llm.models.is_empty());
        let model = llm
            .providers
            .get("openrouter")
            .unwrap()
            .models
            .get("gpt-5.6-sol")
            .unwrap();
        assert_eq!(model.display_name.as_deref(), Some("Configured Sol"));
        assert!(model.provider.is_none());
    }

    #[test]
    fn legacy_builtin_model_id_without_provider_uses_historical_catalog_provider() {
        let parsed = r#"
[llm.models."anthropic/claude-fable-5"]
display_name = "Configured Fable"
"#
        .parse::<SettingsLayer>()
        .unwrap();
        let llm = parsed.llm.unwrap();

        assert!(llm.models.is_empty());
        assert!(
            llm.providers
                .get("openrouter")
                .unwrap()
                .models
                .contains_key("claude-fable-5")
        );
    }

    #[test]
    fn same_model_slug_on_different_providers_merges_independently() {
        use crate::layers::Combine as _;

        let direct = r#"
[llm.providers.openai.models.shared]
display_name = "Direct"
"#
        .parse::<SettingsLayer>()
        .unwrap();
        let aggregator = r#"
[llm.providers.openrouter.models.shared]
display_name = "Aggregator"
"#
        .parse::<SettingsLayer>()
        .unwrap();

        let merged = direct.combine(aggregator);
        let providers = merged.llm.unwrap().providers;
        assert_eq!(
            providers
                .get("openai")
                .unwrap()
                .models
                .get("shared")
                .unwrap()
                .display_name
                .as_deref(),
            Some("Direct")
        );
        assert_eq!(
            providers
                .get("openrouter")
                .unwrap()
                .models
                .get("shared")
                .unwrap()
                .display_name
                .as_deref(),
            Some("Aggregator")
        );
    }

    #[test]
    fn rejects_legacy_llm_provider_key_with_run_model_hint() {
        let err = "[llm]\nprovider = \"openai\"\n"
            .parse::<SettingsLayer>()
            .unwrap_err();
        let text = err.to_string();
        assert!(
            text.contains("run.model") || text.contains("llm"),
            "got: {text}"
        );
    }

    #[test]
    fn rejects_legacy_llm_model_key_with_run_model_hint() {
        let err = "[llm]\nmodel = \"opus\"\n"
            .parse::<SettingsLayer>()
            .unwrap_err();
        let text = err.to_string();
        assert!(
            text.contains("run.model") || text.contains("llm"),
            "got: {text}"
        );
    }
}
