use compact_str::CompactString;
use rustc_hash::{FxHashMap, FxHashSet};
use shuck_ast::Name;
use shuck_semantic::{
    Binding, BindingAttributes, BindingKind, DeclarationBuiltin, DeclarationOperand, ReferenceKind,
    ScopeId,
};

use crate::{Checker, Diagnostic, Rule, ShellDialect, Violation};

pub struct ImplicitGlobalInFunction {
    pub name: CompactString,
}

impl Violation for ImplicitGlobalInFunction {
    fn rule() -> Rule {
        Rule::ImplicitGlobalInFunction
    }

    fn message(&self) -> String {
        format!(
            "assignment to `{}` inside a function is not declared local",
            self.name
        )
    }
}

pub fn implicit_global_in_function(checker: &mut Checker) {
    if checker.shell() != ShellDialect::Bash {
        return;
    }

    let semantic = checker.semantic();
    let analysis = checker.semantic_analysis();
    let options = &checker.rule_options().c158;
    let documented_globals = documented_global_names(
        semantic,
        options.treat_readonly_as_documented,
        options.treat_export_as_intentional,
    );
    let local_declarations = function_local_declarations(semantic);
    let diagnostics = semantic
        .bindings()
        .iter()
        .filter(|binding| binding_can_mutate_function_global(binding))
        .filter(|binding| !documented_globals.contains(&binding.name))
        .filter(|binding| !analysis.scope_runs_in_transient_context(binding.scope))
        .filter_map(|binding| {
            let function_scope = semantic.enclosing_function_scope(binding.scope)?;
            let has_local_declaration = local_declarations
                .get(&(function_scope, binding.name.clone()))
                .is_some_and(|offsets| {
                    offsets
                        .iter()
                        .any(|offset| *offset <= binding.span.start.offset())
                });
            (!has_local_declaration).then(|| {
                Diagnostic::new(
                    ImplicitGlobalInFunction {
                        name: binding.name.as_str().into(),
                    },
                    binding.span,
                )
            })
        })
        .collect::<Vec<_>>();

    for diagnostic in diagnostics {
        checker.report_diagnostic_dedup(diagnostic);
    }
}

fn documented_global_names(
    semantic: &shuck_semantic::SemanticModel,
    treat_readonly_as_documented: bool,
    treat_export_as_intentional: bool,
) -> FxHashSet<Name> {
    let mut documented = semantic
        .bindings()
        .iter()
        .filter(|binding| binding_is_top_level_declaration_site(semantic, binding))
        .filter(|binding| {
            declaration_binding_documents_global(
                semantic,
                binding,
                treat_readonly_as_documented,
                treat_export_as_intentional,
            )
        })
        .map(|binding| binding.name.clone())
        .collect::<FxHashSet<_>>();

    let documented_attribute_names = semantic
        .bindings()
        .iter()
        .filter(|binding| binding_is_file_scoped(binding, semantic))
        .filter(|binding| {
            (treat_readonly_as_documented && binding_documents_readonly(binding))
                || (treat_export_as_intentional && binding_documents_export(binding))
        })
        .map(|binding| binding.name.clone())
        .collect::<FxHashSet<_>>();

    documented.extend(
        semantic
            .references()
            .iter()
            .filter(|reference| reference.kind == ReferenceKind::DeclarationName)
            .filter(|reference| reference_is_top_level(semantic, reference))
            .filter(|reference| documented_attribute_names.contains(&reference.name))
            .filter(|reference| {
                declaration_reference_documents_global(
                    semantic,
                    reference,
                    treat_readonly_as_documented,
                    treat_export_as_intentional,
                )
            })
            .map(|reference| reference.name.clone()),
    );

    documented
}

fn declaration_binding_documents_global(
    semantic: &shuck_semantic::SemanticModel,
    binding: &Binding,
    treat_readonly_as_documented: bool,
    treat_export_as_intentional: bool,
) -> bool {
    semantic.declarations().iter().any(|declaration| {
        declaration_covers_binding(declaration, binding)
            && ((treat_readonly_as_documented && declaration_documents_readonly(declaration))
                || (treat_export_as_intentional && declaration_documents_export(declaration)))
    })
}

fn declaration_reference_documents_global(
    semantic: &shuck_semantic::SemanticModel,
    reference: &shuck_semantic::Reference,
    treat_readonly_as_documented: bool,
    treat_export_as_intentional: bool,
) -> bool {
    semantic.declarations().iter().any(|declaration| {
        declaration_covers_reference(declaration, reference)
            && ((treat_readonly_as_documented && declaration_documents_readonly(declaration))
                || (treat_export_as_intentional && declaration_documents_export(declaration)))
    })
}

fn declaration_covers_binding(
    declaration: &shuck_semantic::Declaration,
    binding: &Binding,
) -> bool {
    declaration.operands.iter().any(|operand| match operand {
        DeclarationOperand::Name { name, span } => name == &binding.name && *span == binding.span,
        DeclarationOperand::Assignment {
            name,
            target_span,
            name_span,
            ..
        } => name == &binding.name && (*name_span == binding.span || *target_span == binding.span),
        DeclarationOperand::Flag { .. } | DeclarationOperand::DynamicWord { .. } => false,
    })
}

fn declaration_covers_reference(
    declaration: &shuck_semantic::Declaration,
    reference: &shuck_semantic::Reference,
) -> bool {
    declaration.operands.iter().any(|operand| match operand {
        DeclarationOperand::Name { name, span } => {
            name == &reference.name && *span == reference.span
        }
        DeclarationOperand::Assignment {
            name_span, name, ..
        } => name == &reference.name && *name_span == reference.span,
        DeclarationOperand::Flag { .. } | DeclarationOperand::DynamicWord { .. } => false,
    })
}

fn declaration_documents_readonly(declaration: &shuck_semantic::Declaration) -> bool {
    matches!(declaration.builtin, DeclarationBuiltin::Readonly)
        || declaration_has_flag(declaration, 'r')
}

fn declaration_documents_export(declaration: &shuck_semantic::Declaration) -> bool {
    if declaration_has_flag(declaration, 'n') || declaration_disables_flag(declaration, 'x') {
        return false;
    }

    matches!(declaration.builtin, DeclarationBuiltin::Export)
        || declaration_has_flag(declaration, 'x')
}

fn declaration_has_flag(declaration: &shuck_semantic::Declaration, flag: char) -> bool {
    declaration_flag_state(declaration, flag) == Some(true)
}

fn declaration_disables_flag(declaration: &shuck_semantic::Declaration, flag: char) -> bool {
    declaration_flag_state(declaration, flag) == Some(false)
}

fn declaration_flag_state(declaration: &shuck_semantic::Declaration, flag: char) -> Option<bool> {
    let mut state = None;
    for operand in &declaration.operands {
        let DeclarationOperand::Flag { flags, .. } = operand else {
            continue;
        };
        let mut chars = flags.chars();
        let Some(polarity) = chars.next() else {
            continue;
        };
        let enabled = match polarity {
            '-' => true,
            '+' => false,
            _ => continue,
        };
        if chars.any(|candidate| candidate == flag) {
            state = Some(enabled);
        }
    }
    state
}

fn binding_is_top_level_declaration_site(
    semantic: &shuck_semantic::SemanticModel,
    binding: &Binding,
) -> bool {
    binding_is_file_scoped(binding, semantic) && matches!(binding.kind, BindingKind::Declaration(_))
}

fn binding_is_file_scoped(binding: &Binding, semantic: &shuck_semantic::SemanticModel) -> bool {
    matches!(
        semantic.scope_kind(binding.scope),
        shuck_semantic::ScopeKind::File
    ) && matches!(
        semantic.scope_kind(semantic.scope_at(binding.span.start.offset())),
        shuck_semantic::ScopeKind::File
    )
}

fn reference_is_top_level(
    semantic: &shuck_semantic::SemanticModel,
    reference: &shuck_semantic::Reference,
) -> bool {
    matches!(
        semantic.scope_kind(reference.scope),
        shuck_semantic::ScopeKind::File
    ) && matches!(
        semantic.scope_kind(semantic.scope_at(reference.span.start.offset())),
        shuck_semantic::ScopeKind::File
    )
}

fn binding_documents_readonly(binding: &Binding) -> bool {
    binding.attributes.contains(BindingAttributes::READONLY)
        || matches!(
            binding.kind,
            BindingKind::Declaration(DeclarationBuiltin::Readonly)
        )
}

fn binding_documents_export(binding: &Binding) -> bool {
    binding.attributes.contains(BindingAttributes::EXPORTED)
        || matches!(
            binding.kind,
            BindingKind::Declaration(DeclarationBuiltin::Export)
        )
}

fn function_local_declarations(
    semantic: &shuck_semantic::SemanticModel,
) -> FxHashMap<(ScopeId, Name), Vec<usize>> {
    let mut declarations = FxHashMap::<(ScopeId, Name), Vec<usize>>::default();

    for binding in semantic.bindings() {
        if !binding_declares_function_local(binding) {
            continue;
        }
        if let Some(function_scope) = semantic.enclosing_function_scope(binding.scope)
            && binding.scope == function_scope
        {
            declarations
                .entry((function_scope, binding.name.clone()))
                .or_default()
                .push(binding.span.start.offset());
        }
    }

    for declaration in semantic.declarations() {
        if !declaration_declares_function_global(declaration) {
            continue;
        }

        let declaration_scope = semantic.scope_at(declaration.span.start.offset());
        let Some(function_scope) = semantic.enclosing_function_scope(declaration_scope) else {
            continue;
        };
        if declaration_scope != function_scope {
            continue;
        }

        for (name, offset) in declaration_operand_names(declaration) {
            declarations
                .entry((function_scope, name.clone()))
                .or_default()
                .push(offset);
        }
    }

    declarations
}

fn declaration_declares_function_global(declaration: &shuck_semantic::Declaration) -> bool {
    matches!(
        declaration.builtin,
        DeclarationBuiltin::Declare | DeclarationBuiltin::Typeset
    ) && declaration_has_flag(declaration, 'g')
}

fn declaration_operand_names(
    declaration: &shuck_semantic::Declaration,
) -> impl Iterator<Item = (&Name, usize)> {
    declaration
        .operands
        .iter()
        .filter_map(|operand| match operand {
            DeclarationOperand::Name { name, span } => Some((name, span.start.offset())),
            DeclarationOperand::Assignment {
                name, name_span, ..
            } => Some((name, name_span.start.offset())),
            DeclarationOperand::Flag { .. } | DeclarationOperand::DynamicWord { .. } => None,
        })
}

fn binding_declares_function_local(binding: &Binding) -> bool {
    binding.attributes.contains(BindingAttributes::LOCAL)
        || matches!(
            binding.kind,
            BindingKind::Declaration(
                DeclarationBuiltin::Declare
                    | DeclarationBuiltin::Local
                    | DeclarationBuiltin::Readonly
                    | DeclarationBuiltin::Typeset
            ) | BindingKind::Nameref
        )
}

fn binding_can_mutate_function_global(binding: &Binding) -> bool {
    matches!(
        binding.kind,
        BindingKind::Assignment
            | BindingKind::ParameterDefaultAssignment
            | BindingKind::AppendAssignment
            | BindingKind::ArrayAssignment
            | BindingKind::LoopVariable
            | BindingKind::ReadTarget
            | BindingKind::MapfileTarget
            | BindingKind::PrintfTarget
            | BindingKind::GetoptsTarget
            | BindingKind::ZparseoptsTarget
            | BindingKind::ArithmeticAssignment
    )
}

#[cfg(test)]
mod tests {
    use crate::test::test_snippet;
    use crate::{LinterSettings, Rule};

    #[test]
    fn reports_function_assignments_without_prior_local_declarations() {
        let source = "\
#!/bin/bash
work() {
  item=1
  item+=2
  for loop in a b; do
    :
  done
  read -r line
  printf -v rendered '%s' \"$item\"
  (( total += 1 ))
}
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::ImplicitGlobalInFunction),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["item", "item", "loop", "line", "rendered", "total"]
        );
    }

    #[test]
    fn accepts_assignments_after_function_scope_declarations() {
        let source = "\
#!/bin/bash
work() {
  local item=1
  item=2
  declare path
  path=/tmp
  readonly pinned=1
  pinned=2
}
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::ImplicitGlobalInFunction),
        );

        assert!(diagnostics.is_empty(), "{diagnostics:#?}");
    }

    #[test]
    fn accepts_assignments_after_explicit_function_global_declarations() {
        let source = "\
#!/bin/bash
work() {
  declare -g shared
  shared=1
  typeset -g other=0
  other=1
}
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::ImplicitGlobalInFunction),
        );

        assert!(diagnostics.is_empty(), "{diagnostics:#?}");
    }

    #[test]
    fn later_local_declarations_do_not_hide_earlier_global_assignments() {
        let source = "\
#!/bin/bash
work() {
  late=1
  local late
  late=2
}
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::ImplicitGlobalInFunction),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["late"]
        );
    }

    #[test]
    fn treats_documented_global_bindings_as_intentional_by_default() {
        let source = "\
#!/bin/bash
readonly PINNED=1
export SHARED=old
work() {
  PINNED=2
  SHARED=new
}
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::ImplicitGlobalInFunction),
        );

        assert!(diagnostics.is_empty(), "{diagnostics:#?}");
    }

    #[test]
    fn treats_top_level_name_only_export_and_readonly_as_documenting_existing_globals() {
        let source = "\
#!/bin/bash
PINNED=old
readonly PINNED
SHARED=old
export SHARED
work() {
  PINNED=new
  SHARED=new
}
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::ImplicitGlobalInFunction),
        );

        assert!(diagnostics.is_empty(), "{diagnostics:#?}");
    }

    #[test]
    fn unexport_declarations_do_not_document_globals() {
        let source = "\
#!/bin/bash
SHARED=old
export -n SHARED
work() {
  SHARED=new
}
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::ImplicitGlobalInFunction),
        );

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].span.slice(source), "SHARED");
        assert_eq!(diagnostics[0].span.start.line(), 5);
    }

    #[test]
    fn function_export_does_not_document_existing_file_global() {
        let source = "\
#!/bin/bash
SHARED=old
mark_exported() {
  export SHARED
}
work() {
  SHARED=new
}
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::ImplicitGlobalInFunction),
        );

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].span.slice(source), "SHARED");
        assert_eq!(diagnostics[0].span.start.line(), 7);
    }

    #[test]
    fn unrelated_top_level_declare_does_not_document_function_exported_global() {
        let source = "\
#!/bin/bash
SHARED=old
mark_exported() {
  export SHARED
}
declare SHARED
work() {
  SHARED=new
}
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::ImplicitGlobalInFunction),
        );

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].span.slice(source), "SHARED");
        assert_eq!(diagnostics[0].span.start.line(), 8);
    }

    #[test]
    fn function_global_declarations_do_not_document_file_globals() {
        let source = "\
#!/bin/bash
export DOCUMENTED=1
declare_global() {
  declare -gx SHARED=1
}
work() {
  DOCUMENTED=2
  SHARED=2
}
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::ImplicitGlobalInFunction),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["SHARED"]
        );
    }

    #[test]
    fn options_can_report_readonly_and_exported_globals() {
        let source = "\
#!/bin/bash
readonly PINNED=1
export SHARED=old
work() {
  PINNED=2
  SHARED=new
}
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::ImplicitGlobalInFunction)
                .with_c158_treat_readonly_as_documented(false)
                .with_c158_treat_export_as_intentional(false),
        );

        assert_eq!(
            diagnostics
                .iter()
                .map(|diagnostic| diagnostic.span.slice(source))
                .collect::<Vec<_>>(),
            vec!["PINNED", "SHARED"]
        );
    }

    #[test]
    fn ignores_other_shells_transient_scopes_and_default_settings() {
        let sh_source = "\
#!/bin/sh
work() {
  item=1
}
";
        let transient_source = "\
#!/bin/bash
work() {
  (item=1)
  echo \"$(nested=1)\"
}
";

        assert!(
            test_snippet(
                sh_source,
                &LinterSettings::for_rule(Rule::ImplicitGlobalInFunction),
            )
            .is_empty()
        );
        assert!(
            test_snippet(
                transient_source,
                &LinterSettings::for_rule(Rule::ImplicitGlobalInFunction),
            )
            .is_empty()
        );
        assert!(
            test_snippet(sh_source, &LinterSettings::default())
                .iter()
                .all(|diagnostic| diagnostic.rule != Rule::ImplicitGlobalInFunction)
        );
    }
}
