//! Startup warnings, model migration prompts, and bootstrap prompt helpers.
//!
//! These helpers run before or during `App::run` bootstrap. They translate configuration and model
//! catalog state into one-time TUI prompts or warning cells without owning the main event loop.

use super::*;
use std::collections::HashSet;
use std::path::PathBuf;

#[derive(Debug, PartialEq, Eq, Hash)]
struct SkillLoadWarningKey {
    path: PathBuf,
    message: String,
}

#[derive(Debug, Default)]
pub(super) struct SkillLoadWarningState {
    active: HashSet<SkillLoadWarningKey>,
}

impl SkillLoadWarningState {
    pub(super) fn clear(&mut self) {
        self.active.clear();
    }

    pub(super) fn newly_active_errors(&mut self, errors: &[SkillErrorInfo]) -> Vec<SkillErrorInfo> {
        let previous = std::mem::take(&mut self.active);
        let mut current = HashSet::new();
        let mut newly_active = Vec::new();

        for error in errors {
            let key = SkillLoadWarningKey {
                path: error.path.clone(),
                message: error.message.clone(),
            };
            let was_active = previous.contains(&key);
            if current.insert(key) && !was_active {
                newly_active.push(error.clone());
            }
        }

        self.active = current;
        newly_active
    }
}

pub(super) fn emit_skill_load_warnings(app_event_tx: &AppEventSender, errors: &[SkillErrorInfo]) {
    if errors.is_empty() {
        return;
    }

    let error_count = errors.len();
    app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
        crate::history_cell::new_warning_event(format!(
            "Skipped loading {error_count} skill(s) due to invalid SKILL.md files."
        )),
    )));

    for error in errors {
        let path = error.path.display();
        let message = error.message.as_str();
        app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
            crate::history_cell::new_warning_event(format!("{path}: {message}")),
        )));
    }
}

pub(super) fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) {
    let mut disabled_folders = Vec::new();

    for layer in config.config_layer_stack.get_layers(
        ConfigLayerStackOrdering::LowestPrecedenceFirst,
        /*include_disabled*/ true,
    ) {
        let ConfigLayerSource::Project { dot_codex_folder } = &layer.name else {
            continue;
        };
        let Some(disabled_reason) = &layer.disabled_reason else {
            continue;
        };
        disabled_folders.push((
            dot_codex_folder.as_path().display().to_string(),
            disabled_reason.clone(),
        ));
    }

    if disabled_folders.is_empty() {
        return;
    }

    let mut message = concat!(
        "Project-local config, hooks, and exec policies are disabled in the following folders ",
        "until the project is trusted, but skills still load.\n",
    )
    .to_string();
    for (index, (folder, reason)) in disabled_folders.iter().enumerate() {
        let display_index = index + 1;
        message.push_str(&format!("    {display_index}. {folder}\n"));
        message.push_str(&format!("       {reason}\n"));
    }

    app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
        history_cell::new_warning_event(message),
    )));
}

pub(super) fn emit_system_bwrap_warning(app_event_tx: &AppEventSender, config: &Config) {
    let Some(message) =
        codex_sandboxing::system_bwrap_warning(config.permissions.permission_profile())
    else {
        return;
    };

    app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
        history_cell::new_warning_event(message),
    )));
}

pub(super) fn should_show_model_migration_prompt(
    current_model: &str,
    target_model: &str,
    seen_migrations: &BTreeMap<String, String>,
    available_models: &[ModelPreset],
) -> bool {
    if target_model == current_model {
        return false;
    }

    if let Some(seen_target) = seen_migrations.get(current_model)
        && seen_target == target_model
    {
        return false;
    }

    if !available_models
        .iter()
        .any(|preset| preset.model == target_model && preset.show_in_picker)
    {
        return false;
    }

    if available_models
        .iter()
        .any(|preset| preset.model == current_model && preset.upgrade.is_some())
    {
        return true;
    }

    if available_models
        .iter()
        .any(|preset| preset.upgrade.as_ref().map(|u| u.id.as_str()) == Some(target_model))
    {
        return true;
    }

    false
}

pub(super) fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> bool {
    match migration_config_key {
        HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => config
            .notices
            .hide_gpt_5_1_codex_max_migration_prompt
            .unwrap_or(false),
        HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG => {
            config.notices.hide_gpt5_1_migration_prompt.unwrap_or(false)
        }
        _ => false,
    }
}

pub(super) fn target_preset_for_upgrade<'a>(
    available_models: &'a [ModelPreset],
    target_model: &str,
) -> Option<&'a ModelPreset> {
    available_models
        .iter()
        .find(|preset| preset.model == target_model && preset.show_in_picker)
}

pub(super) fn apply_accepted_model_migration(
    config: &mut Config,
    app_event_tx: &AppEventSender,
    from_model: String,
    target_model: String,
    target_default_effort: ReasoningEffortConfig,
) {
    app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged {
        from_model,
        to_model: target_model.clone(),
    });

    config.model = Some(target_model.clone());
    config.model_reasoning_effort = Some(target_default_effort.clone());
    app_event_tx.send(AppEvent::UpdateModel(target_model.clone()));
    app_event_tx.send(AppEvent::UpdateReasoningEffort(Some(
        target_default_effort.clone(),
    )));
    app_event_tx.send(AppEvent::PersistModelSelection {
        model: target_model,
        effort: Some(target_default_effort),
    });
}

pub(super) const MODEL_AVAILABILITY_NUX_MAX_SHOW_COUNT: u32 = 4;

#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct StartupTooltipOverride {
    pub(super) model_slug: String,
    pub(super) message: String,
}

pub(super) fn select_model_availability_nux(
    available_models: &[ModelPreset],
    nux_config: &ModelAvailabilityNuxConfig,
) -> Option<StartupTooltipOverride> {
    available_models.iter().find_map(|preset| {
        let ModelAvailabilityNux { message } = preset.availability_nux.as_ref()?;
        let shown_count = nux_config
            .shown_count
            .get(&preset.model)
            .copied()
            .unwrap_or_default();
        (shown_count < MODEL_AVAILABILITY_NUX_MAX_SHOW_COUNT).then(|| StartupTooltipOverride {
            model_slug: preset.model.clone(),
            message: message.clone(),
        })
    })
}

pub(super) async fn prepare_startup_tooltip_override(
    config: &mut Config,
    available_models: &[ModelPreset],
    is_first_run: bool,
) -> Option<String> {
    if is_first_run || !config.show_tooltips {
        return None;
    }

    let tooltip_override =
        select_model_availability_nux(available_models, &config.model_availability_nux)?;

    let shown_count = config
        .model_availability_nux
        .shown_count
        .get(&tooltip_override.model_slug)
        .copied()
        .unwrap_or_default();
    let next_count = shown_count.saturating_add(1);
    let mut updated_shown_count = config.model_availability_nux.shown_count.clone();
    updated_shown_count.insert(tooltip_override.model_slug.clone(), next_count);

    if let Err(err) = ConfigEditsBuilder::for_config(config)
        .set_model_availability_nux_count(&updated_shown_count)
        .apply()
        .await
    {
        tracing::error!(
            error = %err,
            model = %tooltip_override.model_slug,
            "failed to persist model availability nux count"
        );
        return Some(tooltip_override.message);
    }

    config.model_availability_nux.shown_count = updated_shown_count;
    Some(tooltip_override.message)
}

pub(super) async fn handle_model_migration_prompt_if_needed(
    tui: &mut tui::Tui,
    config: &mut Config,
    model: &str,
    app_event_tx: &AppEventSender,
    available_models: &[ModelPreset],
) -> Option<AppExitInfo> {
    let upgrade = available_models
        .iter()
        .find(|preset| preset.model == model)
        .and_then(|preset| preset.upgrade.as_ref());

    if let Some(ModelUpgrade {
        id: target_model,
        migration_config_key,
        model_link,
        upgrade_copy,
        migration_markdown,
    }) = upgrade
    {
        if migration_prompt_hidden(config, migration_config_key.as_str()) {
            return None;
        }

        let target_model = target_model.to_string();
        if !should_show_model_migration_prompt(
            model,
            &target_model,
            &config.notices.model_migrations,
            available_models,
        ) {
            return None;
        }

        let current_preset = available_models.iter().find(|preset| preset.model == model);
        let target_preset = target_preset_for_upgrade(available_models, &target_model);
        let target_preset = target_preset?;
        let target_display_name = target_preset.display_name.clone();
        let heading_label = if target_display_name == model {
            target_model.clone()
        } else {
            target_display_name.clone()
        };
        let target_description =
            (!target_preset.description.is_empty()).then(|| target_preset.description.clone());
        let can_opt_out = current_preset.is_some();
        let prompt_copy = migration_copy_for_models(
            model,
            &target_model,
            model_link.clone(),
            upgrade_copy.clone(),
            migration_markdown.clone(),
            heading_label,
            target_description,
            can_opt_out,
        );
        match run_model_migration_prompt(tui, prompt_copy).await {
            ModelMigrationOutcome::Accepted => {
                apply_accepted_model_migration(
                    config,
                    app_event_tx,
                    model.to_string(),
                    target_model.clone(),
                    target_preset.default_reasoning_effort.clone(),
                );
            }
            ModelMigrationOutcome::Rejected => {
                app_event_tx.send(AppEvent::PersistModelMigrationPromptAcknowledged {
                    from_model: model.to_string(),
                    to_model: target_model.clone(),
                });
            }
            ModelMigrationOutcome::Exit => {
                return Some(AppExitInfo {
                    token_usage: TokenUsage::default(),
                    thread_id: None,
                    resume_hint: None,
                    update_action: None,
                    exit_reason: ExitReason::UserRequested,
                });
            }
        }
    }

    None
}
pub(super) fn normalize_harness_overrides_for_cwd(
    mut overrides: ConfigOverrides,
    base_cwd: &AbsolutePathBuf,
) -> Result<ConfigOverrides> {
    if overrides.additional_writable_roots.is_empty() {
        return Ok(overrides);
    }

    let mut normalized = Vec::with_capacity(overrides.additional_writable_roots.len());
    for root in overrides.additional_writable_roots.drain(..) {
        let absolute = base_cwd.join(root);
        normalized.push(absolute.into_path_buf());
    }
    overrides.additional_writable_roots = normalized;
    Ok(overrides)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_support::PathBufExt;
    use pretty_assertions::assert_eq;
    use ratatui::text::Line;
    use std::path::PathBuf;
    use tempfile::tempdir;
    use tokio::sync::mpsc::unbounded_channel;

    #[test]
    fn normalize_harness_overrides_resolves_relative_add_dirs() -> Result<()> {
        let temp_dir = tempdir()?;
        let base_cwd = temp_dir.path().join("base").abs();
        std::fs::create_dir_all(base_cwd.as_path())?;

        let overrides = ConfigOverrides {
            additional_writable_roots: vec![PathBuf::from("rel")],
            ..Default::default()
        };
        let normalized = normalize_harness_overrides_for_cwd(overrides, &base_cwd)?;

        assert_eq!(
            normalized.additional_writable_roots,
            vec![base_cwd.join("rel").into_path_buf()]
        );
        Ok(())
    }

    fn skill_error(path: &str, message: &str) -> SkillErrorInfo {
        SkillErrorInfo {
            path: PathBuf::from(path),
            message: message.to_string(),
        }
    }

    fn render_line_text(line: &Line<'static>) -> String {
        line.spans
            .iter()
            .map(|span| span.content.as_ref())
            .collect()
    }

    fn render_skill_load_warning_cells(errors: &[SkillErrorInfo]) -> String {
        let (tx, mut rx) = unbounded_channel();
        let app_event_tx = AppEventSender::new(tx);

        emit_skill_load_warnings(&app_event_tx, errors);

        let mut rendered = Vec::new();
        while let Ok(AppEvent::InsertHistoryCell(cell)) = rx.try_recv() {
            rendered.extend(
                cell.display_lines(/*width*/ 120)
                    .iter()
                    .map(render_line_text),
            );
        }
        rendered.join("\n")
    }

    #[test]
    fn skill_load_warning_state_suppresses_repeated_active_errors() {
        let mut state = SkillLoadWarningState::default();
        let error = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid description");

        assert_eq!(
            state.newly_active_errors(std::slice::from_ref(&error)),
            vec![error.clone()]
        );
        assert_eq!(
            state.newly_active_errors(std::slice::from_ref(&error)),
            Vec::<SkillErrorInfo>::new()
        );
    }

    #[test]
    fn skill_load_warning_state_reemits_after_error_clears() {
        let mut state = SkillLoadWarningState::default();
        let error = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid description");

        assert_eq!(
            state.newly_active_errors(std::slice::from_ref(&error)),
            vec![error.clone()]
        );
        assert_eq!(state.newly_active_errors(&[]), Vec::<SkillErrorInfo>::new());
        assert_eq!(
            state.newly_active_errors(std::slice::from_ref(&error)),
            vec![error]
        );
    }

    #[test]
    fn skill_load_warning_state_displays_new_message_for_active_path() {
        let mut state = SkillLoadWarningState::default();
        let initial = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid description");
        let changed = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid frontmatter");

        assert_eq!(
            state.newly_active_errors(std::slice::from_ref(&initial)),
            vec![initial]
        );
        assert_eq!(
            state.newly_active_errors(std::slice::from_ref(&changed)),
            vec![changed]
        );
    }

    #[test]
    fn skill_load_warning_state_clear_allows_active_error_again() {
        let mut state = SkillLoadWarningState::default();
        let error = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid description");

        assert_eq!(
            state.newly_active_errors(std::slice::from_ref(&error)),
            vec![error.clone()]
        );
        assert_eq!(
            state.newly_active_errors(std::slice::from_ref(&error)),
            Vec::<SkillErrorInfo>::new()
        );

        state.clear();

        assert_eq!(
            state.newly_active_errors(std::slice::from_ref(&error)),
            vec![error]
        );
    }

    #[test]
    fn repeated_active_skill_load_warning_renders_once() {
        let mut state = SkillLoadWarningState::default();
        let error = skill_error("/repo/.codex/skills/abc/SKILL.md", "invalid description");

        let first_errors = state.newly_active_errors(std::slice::from_ref(&error));
        let repeated_errors = state.newly_active_errors(std::slice::from_ref(&error));
        let rendered = [
            render_skill_load_warning_cells(&first_errors),
            render_skill_load_warning_cells(&repeated_errors),
        ]
        .into_iter()
        .filter(|output| !output.is_empty())
        .collect::<Vec<_>>()
        .join("\n");

        insta::assert_snapshot!(rendered, @r"
⚠ Skipped loading 1 skill(s) due to invalid SKILL.md files.
⚠ /repo/.codex/skills/abc/SKILL.md: invalid description
");
    }
}
