//! Oh My Zsh style plugin request discovery.
//!
//! This module recognizes framework/plugin declarations that imply additional
//! files should be analyzed as part of a zsh source closure. It emits logical
//! `PluginRequest`s only; resolving those requests to concrete files remains the
//! job of the configured `PluginResolver`.

use super::super::*;
use super::*;

pub(super) struct OhMyZshPluginManager;

impl ZshPluginManager for OhMyZshPluginManager {
    fn collect_plugin_requests(&self, context: &PluginManagerContext<'_>) -> Vec<PluginRequest> {
        collect_oh_my_zsh_plugin_requests(context)
    }
}

impl ZshPluginFramework for OhMyZshPluginManager {
    fn framework(&self) -> PluginFramework {
        PluginFramework::OhMyZsh
    }

    fn root_keys(&self) -> &'static [&'static str] {
        &["oh-my-zsh"]
    }

    fn resolve_plugin_entrypoint(&self, root: &Path, name: &str) -> Option<PathBuf> {
        Some(
            root.join("plugins")
                .join(name)
                .join(format!("{name}.plugin.zsh")),
        )
    }

    fn resolve_theme_entrypoint(&self, root: &Path, name: &str) -> Option<PathBuf> {
        Some(root.join("themes").join(format!("{name}.zsh-theme")))
    }

    fn resolve_source_suffix(
        &self,
        root: &Path,
        source_path: &Path,
        candidate: &str,
    ) -> Option<PathBuf> {
        let normalized = candidate.replace('\\', "/");
        if normalized == "oh-my-zsh.sh" || normalized.ends_with("/oh-my-zsh.sh") {
            return Some(PathBuf::from("oh-my-zsh.sh"));
        }

        let source_in_framework = source_path.starts_with(root);
        let candidate_has_framework_anchor = path_text_has_oh_my_zsh_anchor(&normalized)
            || path_text_starts_with_path(&normalized, root);
        if !source_in_framework && !candidate_has_framework_anchor {
            return None;
        }

        suffix_after_last_marker(&normalized, ["/plugins/", "/themes/", "/lib/"])
    }
}

fn path_text_has_oh_my_zsh_anchor(path: &str) -> bool {
    path.contains("/.oh-my-zsh/") || path.contains("/oh-my-zsh/")
}

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

    #[test]
    fn root_keys_match_config_name() {
        assert_eq!(OhMyZshPluginManager.root_keys(), &["oh-my-zsh"]);
    }

    #[test]
    fn resolves_plugin_and_theme_entrypoints() {
        let root = Path::new("/workspace/.oh-my-zsh");

        assert_eq!(
            OhMyZshPluginManager.resolve_plugin_entrypoint(root, "git"),
            Some(PathBuf::from(
                "/workspace/.oh-my-zsh/plugins/git/git.plugin.zsh"
            ))
        );
        assert_eq!(
            OhMyZshPluginManager.resolve_entrypoint(root, PluginRequestKind::Plugin, "fzf"),
            Some(PathBuf::from(
                "/workspace/.oh-my-zsh/plugins/fzf/fzf.plugin.zsh"
            ))
        );
        assert_eq!(
            OhMyZshPluginManager.resolve_theme_entrypoint(root, "agnoster"),
            Some(PathBuf::from(
                "/workspace/.oh-my-zsh/themes/agnoster.zsh-theme"
            ))
        );
        assert_eq!(
            OhMyZshPluginManager.resolve_entrypoint(root, PluginRequestKind::Theme, "robbyrussell"),
            Some(PathBuf::from(
                "/workspace/.oh-my-zsh/themes/robbyrussell.zsh-theme"
            ))
        );
    }

    #[test]
    fn resolves_framework_entrypoint_source_suffix() {
        assert_eq!(
            OhMyZshPluginManager.resolve_source_suffix(
                Path::new("/workspace/.oh-my-zsh"),
                Path::new("/workspace/app/.zshrc"),
                "/not-installed/.oh-my-zsh/oh-my-zsh.sh",
            ),
            Some(PathBuf::from("oh-my-zsh.sh"))
        );
    }

    #[test]
    fn resolves_framework_relative_source_suffixes() {
        let root = Path::new("/workspace/.oh-my-zsh");

        assert_eq!(
            OhMyZshPluginManager.resolve_source_suffix(
                root,
                Path::new("/workspace/.oh-my-zsh/oh-my-zsh.sh"),
                "/opt/app/plugins/git/git.plugin.zsh",
            ),
            Some(PathBuf::from("plugins/git/git.plugin.zsh"))
        );
        assert_eq!(
            OhMyZshPluginManager.resolve_source_suffix(
                root,
                Path::new("/workspace/.oh-my-zsh/oh-my-zsh.sh"),
                "/opt/app/themes/agnoster.zsh-theme",
            ),
            Some(PathBuf::from("themes/agnoster.zsh-theme"))
        );
        assert_eq!(
            OhMyZshPluginManager.resolve_source_suffix(
                root,
                Path::new("/workspace/.oh-my-zsh/oh-my-zsh.sh"),
                "/opt/app/lib/git.zsh",
            ),
            Some(PathBuf::from("lib/git.zsh"))
        );
    }

    #[test]
    fn rejects_unanchored_source_suffixes_outside_framework() {
        assert_eq!(
            OhMyZshPluginManager.resolve_source_suffix(
                Path::new("/workspace/.oh-my-zsh"),
                Path::new("/workspace/app/.zshrc"),
                "/opt/app/plugins/git/git.plugin.zsh",
            ),
            None
        );
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum PluginListState {
    Unset,
    Static(Vec<String>),
    Dynamic,
}

#[derive(Debug, Clone)]
struct DetectedPluginBootstrap {
    framework: PluginFramework,
    span: Span,
    root_hint: Option<PathBuf>,
}

pub(in crate::source_closure) fn sorted_dependency_paths(
    paths: &FxHashSet<PathBuf>,
) -> Vec<PathBuf> {
    let mut sorted = paths.iter().cloned().collect::<Vec<_>>();
    sorted.sort();
    sorted.dedup();
    sorted
}

fn collect_oh_my_zsh_plugin_requests(context: &PluginManagerContext<'_>) -> Vec<PluginRequest> {
    let mut path_templates = FxHashMap::<Name, SourcePathTemplate>::default();
    let home_dir = env::var_os("HOME").map(PathBuf::from);
    let mut plugin_state = PluginListState::Unset;
    let mut theme_name = None::<String>;
    let mut requests = Vec::new();
    let mut bootstraps = Vec::new();

    for stmt in &context.file.body.stmts {
        for assignment in top_level_assignments(stmt) {
            if assignment.target.subscript.is_none()
                && let Some(template) = assignment_path_template(
                    assignment,
                    context.source,
                    &path_templates,
                    home_dir.as_deref(),
                )
            {
                path_templates.insert(assignment.target.name.clone(), template);
            }

            match assignment.target.name.as_str() {
                "plugins" => {
                    plugin_state = plugin_list_state_after_assignment(
                        &plugin_state,
                        assignment,
                        context.source,
                    );
                }
                "ZSH_THEME" => {
                    theme_name = assignment_theme_name(assignment, context.source);
                }
                _ => {}
            }
        }

        let Some(bootstrap) = detect_plugin_bootstrap(
            stmt,
            context.source,
            context.source_path,
            &path_templates,
            home_dir.as_deref(),
        ) else {
            continue;
        };
        bootstraps.push(bootstrap.clone());

        if let PluginListState::Static(names) = &plugin_state {
            for name in names {
                requests.push(PluginRequest {
                    framework: bootstrap.framework.clone(),
                    kind: PluginRequestKind::Plugin,
                    name: name.clone(),
                    span: bootstrap.span,
                    explicit: false,
                    root_hint: bootstrap.root_hint.clone(),
                });
            }
        }

        if let Some(theme_name) = theme_name.as_ref().filter(|name| !name.contains('/')) {
            requests.push(PluginRequest {
                framework: bootstrap.framework.clone(),
                kind: PluginRequestKind::Theme,
                name: theme_name.clone(),
                span: bootstrap.span,
                explicit: false,
                root_hint: bootstrap.root_hint.clone(),
            });
        }
    }

    requests.extend(anchor_configured_plugin_requests(
        context
            .plugin_resolver
            .additional_plugin_requests(context.source_path),
        &bootstraps,
        context.file.span,
    ));
    requests
}

fn top_level_assignments(stmt: &shuck_ast::Stmt) -> Vec<&Assignment> {
    match &stmt.command {
        Command::Simple(command) => command.assignments.iter().collect(),
        Command::Decl(command) => command
            .assignments
            .iter()
            .chain(command.operands.iter().filter_map(|operand| match operand {
                DeclOperand::Assignment(assignment) => Some(assignment),
                _ => None,
            }))
            .collect(),
        _ => Vec::new(),
    }
}

fn assignment_path_template(
    assignment: &Assignment,
    source: &str,
    known_templates: &FxHashMap<Name, SourcePathTemplate>,
    home_dir: Option<&Path>,
) -> Option<SourcePathTemplate> {
    let AssignmentValue::Scalar(word) = &assignment.value else {
        return None;
    };
    if let Some(text) = static_word_text(word, source) {
        return static_path_template(&text, home_dir);
    }

    assignment_source_path_template(word, source, false, false, |name, _| {
        resolve_variable_source_template(name, known_templates, home_dir)
    })
}

fn static_path_template(text: &str, home_dir: Option<&Path>) -> Option<SourcePathTemplate> {
    let expanded = expand_static_home_path(text, home_dir)?;
    Some(SourcePathTemplate::Interpolated(vec![
        TemplatePart::Literal(expanded),
    ]))
}

fn resolve_variable_source_template(
    name: &Name,
    known_templates: &FxHashMap<Name, SourcePathTemplate>,
    home_dir: Option<&Path>,
) -> Option<SourcePathTemplate> {
    if name.as_str() == "HOME" {
        return home_dir
            .and_then(|home| static_path_template(&path_to_template_string(home), None));
    }

    known_templates.get(name).cloned()
}

fn expand_static_home_path(text: &str, home_dir: Option<&Path>) -> Option<String> {
    if let Some(home_dir) = home_dir {
        let home = path_to_template_string(home_dir);
        if text == "~" {
            return Some(home);
        }
        if let Some(stripped) = text.strip_prefix("~/") {
            return Some(format!("{home}/{stripped}"));
        }
    }

    Some(text.to_owned())
}

fn plugin_list_state_after_assignment(
    current: &PluginListState,
    assignment: &Assignment,
    source: &str,
) -> PluginListState {
    if assignment.target.subscript.is_some() {
        return PluginListState::Dynamic;
    }
    let AssignmentValue::Compound(array) = &assignment.value else {
        return PluginListState::Dynamic;
    };
    let Some(names) = static_plugin_names(array.elements.as_slice(), source) else {
        return PluginListState::Dynamic;
    };
    if assignment.append {
        let mut combined = match current {
            PluginListState::Unset => Vec::new(),
            PluginListState::Static(existing) => existing.clone(),
            PluginListState::Dynamic => return PluginListState::Dynamic,
        };
        for name in names {
            if !combined.contains(&name) {
                combined.push(name);
            }
        }
        PluginListState::Static(combined)
    } else {
        PluginListState::Static(names)
    }
}

fn static_plugin_names(elements: &[ArrayElem], source: &str) -> Option<Vec<String>> {
    let mut names = Vec::new();
    for element in elements {
        let ArrayElem::Sequential(value) = element else {
            return None;
        };
        let text = static_word_text(value, source)?;
        let trimmed = text.trim();
        if trimmed.is_empty() {
            continue;
        }
        names.push(trimmed.to_owned());
    }
    Some(names)
}

fn assignment_theme_name(assignment: &Assignment, source: &str) -> Option<String> {
    if assignment.append || assignment.target.subscript.is_some() {
        return None;
    }
    let AssignmentValue::Scalar(word) = &assignment.value else {
        return None;
    };
    let text = static_word_text(word, source)?;
    let trimmed = text.trim();
    (!trimmed.is_empty()).then(|| trimmed.to_owned())
}

fn detect_plugin_bootstrap(
    stmt: &shuck_ast::Stmt,
    source: &str,
    source_path: &Path,
    known_templates: &FxHashMap<Name, SourcePathTemplate>,
    home_dir: Option<&Path>,
) -> Option<DetectedPluginBootstrap> {
    let Command::Simple(command) = &stmt.command else {
        return None;
    };
    let name = static_word_text(&command.name, source)?;
    if !matches!(name.as_ref(), "source" | ".") {
        return None;
    }
    let arg = command.args.first()?;

    let concrete_path =
        bootstrap_argument_path(arg, source, source_path, known_templates, home_dir);
    if concrete_path
        .as_deref()
        .and_then(Path::file_name)
        .is_some_and(|name| name == "oh-my-zsh.sh")
    {
        return Some(DetectedPluginBootstrap {
            framework: PluginFramework::OhMyZsh,
            span: stmt.span,
            root_hint: concrete_path.and_then(|path| path.parent().map(Path::to_path_buf)),
        });
    }

    if let Some(text) = static_word_text(arg, source) {
        if text.trim().ends_with("oh-my-zsh.sh") {
            return Some(DetectedPluginBootstrap {
                framework: PluginFramework::OhMyZsh,
                span: stmt.span,
                root_hint: framework_root_hint(known_templates, source_path, "ZSH"),
            });
        }
        return None;
    }

    let template = assignment_source_path_template(arg, source, false, false, |name, _| {
        resolve_variable_source_template(name, known_templates, home_dir)
    });
    let looks_like_bootstrap = template
        .as_ref()
        .is_some_and(|template| template_ends_with_literal(template, "oh-my-zsh.sh"));
    looks_like_bootstrap.then(|| DetectedPluginBootstrap {
        framework: PluginFramework::OhMyZsh,
        span: stmt.span,
        root_hint: framework_root_hint(known_templates, source_path, "ZSH"),
    })
}

fn bootstrap_argument_path(
    word: &Word,
    source: &str,
    source_path: &Path,
    known_templates: &FxHashMap<Name, SourcePathTemplate>,
    home_dir: Option<&Path>,
) -> Option<PathBuf> {
    if let Some(text) = static_word_text(word, source) {
        let text = expand_static_home_path(&text, home_dir)?;
        let path = PathBuf::from(text.trim());
        return Some(if path.is_absolute() {
            lexical_normalize_path(&path)
        } else {
            lexical_normalize_path(&source_path.parent()?.join(path))
        });
    }

    let template = source_path_template_with_resolver(word, source, false, false, |name, _| {
        resolve_variable_source_template(name, known_templates, home_dir)
    })?;
    if template.ignored_root {
        return None;
    }
    render_source_path_template(&template.template, source_path)
}

fn render_source_path_template(
    template: &SourcePathTemplate,
    source_path: &Path,
) -> Option<PathBuf> {
    let SourcePathTemplate::Interpolated(parts) = template;
    let mut rendered = String::new();
    for part in parts {
        match part {
            TemplatePart::Literal(text) => rendered.push_str(text),
            TemplatePart::SourceDir => {
                rendered.push_str(&path_to_template_string(source_path.parent()?))
            }
            TemplatePart::SourceFile => rendered.push_str(&path_to_template_string(source_path)),
            TemplatePart::Arg(_) => return None,
        }
    }
    let trimmed = rendered.trim();
    if trimmed.is_empty() {
        return None;
    }
    let path = PathBuf::from(trimmed);
    Some(if path.is_absolute() {
        lexical_normalize_path(&path)
    } else {
        lexical_normalize_path(&source_path.parent()?.join(path))
    })
}

fn template_ends_with_literal(template: &SourcePathTemplate, suffix: &str) -> bool {
    match template {
        SourcePathTemplate::Interpolated(parts) => {
            let literal = parts
                .iter()
                .filter_map(|part| match part {
                    TemplatePart::Literal(text) => Some(text.as_str()),
                    _ => None,
                })
                .collect::<String>();
            literal.ends_with(suffix)
        }
    }
}

fn framework_root_hint(
    known_templates: &FxHashMap<Name, SourcePathTemplate>,
    source_path: &Path,
    variable_name: &str,
) -> Option<PathBuf> {
    known_templates
        .get(&Name::from(variable_name))
        .and_then(|template| render_source_path_template(template, source_path))
}

fn anchor_configured_plugin_requests(
    requests: Vec<PluginRequest>,
    bootstraps: &[DetectedPluginBootstrap],
    file_span: Span,
) -> Vec<PluginRequest> {
    let file_scope_tail = Span::at(position_after(file_span.end));
    let mut anchored = Vec::new();
    for request in requests {
        if request.kind == PluginRequestKind::Entrypoint {
            anchored.push(PluginRequest {
                // Configured entrypoints are not tied to a source command. Treat
                // them as late file-scope loads so their contract can consume
                // setup assignments made earlier in the file.
                span: file_scope_tail,
                ..request
            });
            continue;
        }

        let matching = bootstraps
            .iter()
            .filter(|bootstrap| bootstrap.framework == request.framework)
            .collect::<Vec<_>>();
        if matching.is_empty() {
            anchored.push(PluginRequest {
                span: file_span,
                ..request
            });
            continue;
        }

        for bootstrap in matching {
            anchored.push(PluginRequest {
                span: bootstrap.span,
                root_hint: bootstrap
                    .root_hint
                    .clone()
                    .or_else(|| request.root_hint.clone()),
                ..request.clone()
            });
        }
    }
    anchored
}

fn position_after(position: shuck_ast::Position) -> shuck_ast::Position {
    shuck_ast::Position::at(
        position.line(),
        position.column() + 1,
        position.offset() + 1,
    )
}

pub(in crate::source_closure) fn dedup_plugin_requests(
    requests: Vec<PluginRequest>,
) -> Vec<PluginRequest> {
    let mut merged: Vec<PluginRequest> = Vec::new();
    let mut positions = FxHashMap::<
        (
            PluginFramework,
            PluginRequestKind,
            String,
            usize,
            Option<PathBuf>,
        ),
        usize,
    >::default();
    for request in requests {
        let key = (
            request.framework.clone(),
            request.kind,
            request.name.clone(),
            request.span.start.offset(),
            request.root_hint.clone(),
        );
        if let Some(&position) = positions.get(&key) {
            if merged[position].explicit {
                continue;
            }
            merged[position] = request;
        } else {
            positions.insert(key, merged.len());
            merged.push(request);
        }
    }
    merged
}
