use std::collections::{BTreeMap, HashMap, HashSet};

use fabro_model::{
    Catalog, FallbackTarget, Model, ModelSelectionError, ProviderId, ReasoningEffort,
};
use fabro_types::settings::{ModelRef, ResolvedModelRef};
use fabro_types::{RunNoticeCode, RunNoticeLevel};

use crate::Error;

/// Catalog-resolved fallback chains keyed by canonical requested model ID.
///
/// A chain is selected from the original request only. Targets never cause
/// another chain lookup.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ModelFallbackPolicy {
    chains: BTreeMap<String, Vec<FallbackTarget>>,
}

impl ModelFallbackPolicy {
    #[cfg(test)]
    #[must_use]
    pub fn new(chains: BTreeMap<String, Vec<FallbackTarget>>) -> Self {
        Self { chains }
    }

    #[must_use]
    pub fn chain_for<'a>(
        &'a self,
        catalog: &Catalog,
        provider: &ProviderId,
        model: &str,
    ) -> Option<&'a [FallbackTarget]> {
        self.chain_for_canonical(&catalog.canonical_model_id(provider, model))
    }

    /// Look up a chain by an already-canonicalized requested model ID.
    #[must_use]
    pub fn chain_for_canonical(&self, canonical_model: &str) -> Option<&[FallbackTarget]> {
        self.chains.get(canonical_model).map(Vec::as_slice)
    }

    pub fn iter(&self) -> impl Iterator<Item = (&str, &[FallbackTarget])> {
        self.chains
            .iter()
            .map(|(model, chain)| (model.as_str(), chain.as_slice()))
    }

    #[must_use]
    pub fn len(&self) -> usize {
        self.chains.len()
    }

    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.chains.is_empty()
    }
}

/// Server-side result of canonicalizing and filtering configured fallback
/// chains.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ResolvedModelFallbacks {
    pub policy:  ModelFallbackPolicy,
    pub notices: Vec<ModelFallbackNotice>,
}

/// Why a configured fallback candidate was removed from one model's chain.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ModelFallbackNotice {
    ProviderUnconfigured {
        requested_model: String,
        reference:       ModelRef,
        provider:        ProviderId,
    },
    NoConfiguredOffering {
        requested_model: String,
        reference:       ModelRef,
        providers:       Vec<ProviderId>,
    },
    PrimaryNotInCatalog {
        requested_model: String,
        reference:       ModelRef,
        primary:         FallbackTarget,
    },
    NoCompatibleModel {
        requested_model: String,
        reference:       ModelRef,
        provider:        ProviderId,
    },
    Duplicate {
        requested_model: String,
        reference:       ModelRef,
        target:          FallbackTarget,
    },
    NoNearbyReasoningLevel {
        requested_model:  String,
        target:           FallbackTarget,
        requested_effort: ReasoningEffort,
    },
    ChainEmpty {
        requested_model: String,
    },
}

impl ModelFallbackNotice {
    #[must_use]
    pub fn code(&self) -> RunNoticeCode {
        match self {
            Self::ChainEmpty { .. } => RunNoticeCode::ModelFallbackChainEmpty,
            Self::ProviderUnconfigured { .. }
            | Self::NoConfiguredOffering { .. }
            | Self::PrimaryNotInCatalog { .. }
            | Self::NoCompatibleModel { .. }
            | Self::Duplicate { .. }
            | Self::NoNearbyReasoningLevel { .. } => RunNoticeCode::ModelFallbackSkipped,
        }
    }

    #[must_use]
    pub fn level(&self) -> RunNoticeLevel {
        match self {
            Self::Duplicate { .. } => RunNoticeLevel::Info,
            Self::ProviderUnconfigured { .. }
            | Self::NoConfiguredOffering { .. }
            | Self::PrimaryNotInCatalog { .. }
            | Self::NoCompatibleModel { .. }
            | Self::NoNearbyReasoningLevel { .. }
            | Self::ChainEmpty { .. } => RunNoticeLevel::Warn,
        }
    }

    #[must_use]
    pub fn message(&self) -> String {
        match self {
            Self::ProviderUnconfigured {
                requested_model,
                reference,
                provider,
            } => format!(
                "Model fallback `{reference}` for requested model `{requested_model}` was skipped because provider `{provider}` is not configured."
            ),
            Self::NoConfiguredOffering {
                requested_model,
                reference,
                providers,
            } => {
                let providers = providers
                    .iter()
                    .map(ProviderId::to_string)
                    .collect::<Vec<_>>()
                    .join(", ");
                format!(
                    "Model fallback `{reference}` for requested model `{requested_model}` was skipped because none of its providers are configured. It is offered by: {providers}."
                )
            }
            Self::PrimaryNotInCatalog {
                requested_model,
                reference,
                primary,
            } => format!(
                "Model fallback `{reference}` for requested model `{requested_model}` was skipped because `{primary}` is not in the catalog, so there is no capability profile to match against."
            ),
            Self::NoCompatibleModel {
                requested_model,
                reference,
                provider,
            } => format!(
                "Model fallback `{reference}` for requested model `{requested_model}` was skipped because provider `{provider}` has no compatible model."
            ),
            Self::Duplicate {
                requested_model,
                reference,
                target,
            } => format!(
                "Model fallback `{reference}` for requested model `{requested_model}` was skipped because target `{target}` already appears in that chain."
            ),
            Self::NoNearbyReasoningLevel {
                requested_model,
                target,
                requested_effort,
            } => format!(
                "Model fallback `{target}` for requested model `{requested_model}` was skipped because it has no reasoning level near `{requested_effort}`."
            ),
            Self::ChainEmpty { requested_model } => format!(
                "No usable model fallbacks remain for requested model `{requested_model}` after filtering its configured candidates."
            ),
        }
    }
}

/// Resolve every model-keyed fallback chain against the server's catalog and
/// configured-provider snapshot.
///
/// This function must stay at server-side call sites. Offline validation only
/// parses the raw table and cannot canonicalize model aliases.
pub fn resolve_model_fallbacks(
    catalog: &Catalog,
    configured_providers: &[ProviderId],
    configured: &BTreeMap<String, Vec<ModelRef>>,
) -> Result<ResolvedModelFallbacks, Error> {
    let eligible = configured_providers.iter().cloned().collect::<HashSet<_>>();
    let mut resolved = ResolvedModelFallbacks::default();
    let mut raw_key_by_canonical = HashMap::<String, String>::new();

    for (raw_key, references) in configured {
        require_bare_model_key(catalog, raw_key)?;
        let selected =
            catalog.resolve_selection_with_catalog_fallback(Some(raw_key), None, &eligible)?;
        let requested_model = selected.model;

        if let Some(previous) =
            raw_key_by_canonical.insert(requested_model.clone(), raw_key.clone())
        {
            return Err(Error::Precondition(format!(
                "`run.model.fallbacks` keys `{previous}` and `{raw_key}` both resolve to requested model `{requested_model}`"
            )));
        }

        let primary = FallbackTarget::new(&selected.provider, &requested_model);
        let primary_model = catalog.get_on_provider(&selected.provider, &requested_model);
        let mut targets = Vec::new();

        for model_ref in references {
            let target = match resolve_fallback_candidate(
                catalog,
                &requested_model,
                &primary,
                primary_model,
                &eligible,
                model_ref,
            )? {
                FallbackCandidate::Skipped(notice) => {
                    resolved.notices.push(notice);
                    continue;
                }
                FallbackCandidate::Target(target) => target,
            };

            if targets.contains(&target) {
                resolved.notices.push(ModelFallbackNotice::Duplicate {
                    requested_model: requested_model.clone(),
                    reference: model_ref.clone(),
                    target,
                });
            } else {
                targets.push(target);
            }
        }

        if targets.is_empty() {
            resolved.notices.push(ModelFallbackNotice::ChainEmpty {
                requested_model: requested_model.clone(),
            });
        }
        resolved.policy.chains.insert(requested_model, targets);
    }

    Ok(resolved)
}

/// Reject chain keys that name a provider. Keys are requested-model selectors;
/// a provider-qualified key can never match a dispatch-time canonical model
/// ID, so it would be silently dead configuration.
fn require_bare_model_key(catalog: &Catalog, raw_key: &str) -> Result<(), Error> {
    let reference: ModelRef = raw_key
        .parse()
        .map_err(|error| Error::Precondition(format!("`run.model.fallbacks` key: {error}")))?;
    match reference.resolve(catalog) {
        Ok(ResolvedModelRef::Model { provider: None, .. }) => Ok(()),
        Ok(ResolvedModelRef::Model {
            provider: Some(_),
            selector,
        }) => Err(Error::Precondition(format!(
            "`run.model.fallbacks` keys name a requested model; use `{selector}` instead of `{raw_key}`"
        ))),
        Ok(ResolvedModelRef::Provider(provider)) => Err(Error::Precondition(format!(
            "`run.model.fallbacks` key `{raw_key}` names provider `{provider}`; keys must name a requested model"
        ))),
        Err(ambiguous) => Err(Error::Precondition(format!(
            "`run.model.fallbacks` key: {ambiguous}"
        ))),
    }
}

enum FallbackCandidate {
    Target(FallbackTarget),
    Skipped(ModelFallbackNotice),
}

fn resolve_fallback_candidate(
    catalog: &Catalog,
    requested_model: &str,
    primary: &FallbackTarget,
    primary_model: Option<&Model>,
    eligible: &HashSet<ProviderId>,
    model_ref: &ModelRef,
) -> Result<FallbackCandidate, Error> {
    let reference = model_ref.clone();

    Ok(match model_ref.resolve(catalog)? {
        ResolvedModelRef::Provider(provider_name) => {
            let provider = catalog.provider_id(&provider_name)?;
            if !eligible.contains(&provider) {
                return Ok(FallbackCandidate::Skipped(
                    ModelFallbackNotice::ProviderUnconfigured {
                        requested_model: requested_model.to_string(),
                        reference,
                        provider,
                    },
                ));
            }
            let Some(primary_model) = primary_model else {
                return Ok(FallbackCandidate::Skipped(
                    ModelFallbackNotice::PrimaryNotInCatalog {
                        requested_model: requested_model.to_string(),
                        reference,
                        primary: primary.clone(),
                    },
                ));
            };
            match catalog.closest(&provider, primary_model) {
                Some(model) => FallbackCandidate::Target(FallbackTarget::new(provider, &model.id)),
                None => FallbackCandidate::Skipped(ModelFallbackNotice::NoCompatibleModel {
                    requested_model: requested_model.to_string(),
                    reference,
                    provider,
                }),
            }
        }
        ResolvedModelRef::Model {
            provider: Some(provider_name),
            selector,
        } => {
            let provider = catalog.provider_id(&provider_name)?;
            if !eligible.contains(&provider) {
                return Ok(FallbackCandidate::Skipped(
                    ModelFallbackNotice::ProviderUnconfigured {
                        requested_model: requested_model.to_string(),
                        reference,
                        provider,
                    },
                ));
            }
            match catalog.resolve_on_provider(&provider, &selector) {
                Ok(info) => {
                    FallbackCandidate::Target(FallbackTarget::new(&info.provider, &info.id))
                }
                Err(ModelSelectionError::UnknownSelectorOnProvider { .. }) => {
                    FallbackCandidate::Target(FallbackTarget::new(provider, selector))
                }
                Err(error) => return Err(error.into()),
            }
        }
        ResolvedModelRef::Model {
            provider: None,
            selector,
        } => match catalog.select(&selector, None, eligible) {
            Ok(info) => FallbackCandidate::Target(FallbackTarget::new(&info.provider, &info.id)),
            Err(ModelSelectionError::NoEligibleOffering { providers, .. }) => {
                FallbackCandidate::Skipped(ModelFallbackNotice::NoConfiguredOffering {
                    requested_model: requested_model.to_string(),
                    reference,
                    providers,
                })
            }
            Err(ModelSelectionError::UnknownSelector { .. }) => {
                FallbackCandidate::Target(FallbackTarget::new(&primary.provider, selector))
            }
            Err(error) => return Err(error.into()),
        },
    })
}

#[cfg(test)]
mod tests {
    use std::collections::BTreeMap;

    use fabro_model::{Catalog, FallbackTarget, ProviderId};

    use super::{ModelFallbackNotice, resolve_model_fallbacks};

    fn references(values: &[&str]) -> Vec<fabro_types::settings::ModelRef> {
        values
            .iter()
            .map(|value| value.parse().expect("fixture reference should parse"))
            .collect()
    }

    fn openrouter_catalog() -> Catalog {
        let overrides = toml::from_str(
            r"
[providers.openrouter]
enabled = true
",
        )
        .expect("catalog override should parse");
        Catalog::from_builtin_with_overrides(&overrides).expect("catalog should build")
    }

    #[test]
    fn canonicalizes_keys_and_keeps_each_chain_independent() {
        let catalog = openrouter_catalog();
        let eligible = [ProviderId::new("openrouter")];
        let configured = BTreeMap::from([
            ("gpt-sol".to_string(), references(&["claude-opus"])),
            (
                "claude-fable".to_string(),
                references(&["gpt-sol", "claude-opus"]),
            ),
        ]);

        let resolved = resolve_model_fallbacks(&catalog, &eligible, &configured).unwrap();

        assert_eq!(
            resolved
                .policy
                .chain_for(&catalog, &ProviderId::new("openrouter"), "gpt-sol"),
            Some([FallbackTarget::new("openrouter", "claude-opus-5")].as_slice())
        );
        assert_eq!(
            resolved
                .policy
                .chain_for(&catalog, &ProviderId::new("openrouter"), "claude-fable"),
            Some(
                [
                    FallbackTarget::new("openrouter", "gpt-5.6-sol"),
                    FallbackTarget::new("openrouter", "claude-opus-5"),
                ]
                .as_slice()
            )
        );
    }

    #[test]
    fn rejects_aliases_that_define_the_same_requested_model_twice() {
        let catalog = openrouter_catalog();
        let eligible = [ProviderId::new("openrouter")];
        let configured = BTreeMap::from([
            ("gpt-sol".to_string(), references(&["claude-opus"])),
            ("gpt-5.6-sol".to_string(), references(&["claude-fable"])),
        ]);

        let error = resolve_model_fallbacks(&catalog, &eligible, &configured).unwrap_err();

        assert!(
            error
                .to_string()
                .contains("both resolve to requested model"),
            "unexpected error: {error}"
        );
    }

    #[test]
    fn rejects_provider_qualified_keys() {
        let catalog = openrouter_catalog();
        let eligible = [ProviderId::new("openrouter")];
        let configured = BTreeMap::from([(
            "openrouter:gpt-sol".to_string(),
            references(&["claude-opus"]),
        )]);

        let error = resolve_model_fallbacks(&catalog, &eligible, &configured).unwrap_err();

        assert!(
            error.to_string().contains("keys name a requested model"),
            "unexpected error: {error}"
        );
    }

    #[test]
    fn skips_unconfigured_candidates_per_requested_model() {
        let catalog = openrouter_catalog();
        let eligible = [ProviderId::new("openrouter")];
        let configured = BTreeMap::from([(
            "kimi-k3".to_string(),
            references(&["moonshot:kimi-k3", "openrouter:kimi-k3"]),
        )]);

        let resolved = resolve_model_fallbacks(&catalog, &eligible, &configured).unwrap();

        assert_eq!(
            resolved
                .policy
                .chain_for(&catalog, &ProviderId::new("openrouter"), "kimi-k3"),
            Some([FallbackTarget::new("openrouter", "kimi-k3")].as_slice())
        );
        assert!(matches!(
            resolved.notices.as_slice(),
            [ModelFallbackNotice::ProviderUnconfigured {
                requested_model,
                provider,
                ..
            }] if requested_model == "kimi-k3" && provider == &ProviderId::new("moonshot")
        ));
    }

    #[test]
    fn resolves_the_requested_production_policy_as_independent_chains() {
        let catalog = {
            let overrides = toml::from_str(
                r"
[providers.modal]
enabled = true

[providers.openrouter]
enabled = true
",
            )
            .expect("catalog override should parse");
            Catalog::from_builtin_with_overrides(&overrides).expect("catalog should build")
        };
        let eligible = [
            ProviderId::new("modal"),
            ProviderId::new("moonshot"),
            ProviderId::new("openrouter"),
        ];
        let configured = BTreeMap::from([
            (
                "kimi-k3".to_string(),
                references(&["moonshot:kimi-k3", "openrouter:kimi-k3", "claude-opus"]),
            ),
            ("glm-5.2".to_string(), references(&["gpt-sol"])),
            ("gpt-sol".to_string(), references(&["claude-opus"])),
            ("claude-opus".to_string(), references(&["gpt-sol"])),
            ("gpt-terra".to_string(), references(&["claude-opus"])),
            ("gpt-luna".to_string(), references(&["claude-sonnet"])),
            (
                "claude-fable".to_string(),
                references(&["gpt-sol", "claude-opus"]),
            ),
        ]);

        let resolved = resolve_model_fallbacks(&catalog, &eligible, &configured).unwrap();

        assert!(resolved.notices.is_empty());
        let chain = |model: &str| {
            resolved
                .policy
                .chain_for(&catalog, &ProviderId::new("openrouter"), model)
                .expect("requested model should have a chain")
        };
        assert_eq!(chain("kimi-k3"), [
            FallbackTarget::new("moonshot", "kimi-k3"),
            FallbackTarget::new("openrouter", "kimi-k3"),
            FallbackTarget::new("openrouter", "claude-opus-5"),
        ]);
        assert_eq!(chain("glm-5.2"), [FallbackTarget::new(
            "openrouter",
            "gpt-5.6-sol"
        )]);
        assert_eq!(chain("gpt-sol"), [FallbackTarget::new(
            "openrouter",
            "claude-opus-5"
        )]);
        assert_eq!(chain("claude-opus"), [FallbackTarget::new(
            "openrouter",
            "gpt-5.6-sol"
        )]);
        assert_eq!(chain("gpt-terra"), [FallbackTarget::new(
            "openrouter",
            "claude-opus-5"
        )]);
        assert_eq!(chain("gpt-luna"), [FallbackTarget::new(
            "openrouter",
            "claude-sonnet-5"
        )]);
        assert_eq!(chain("claude-fable"), [
            FallbackTarget::new("openrouter", "gpt-5.6-sol"),
            FallbackTarget::new("openrouter", "claude-opus-5"),
        ]);
    }
}
