use compact_str::CompactString;
use rustc_hash::{FxHashMap, FxHashSet};
use shuck_ast::{Name, Span};
use shuck_semantic::{
    Binding, BindingAttributes, BindingId, BindingKind, BindingOrigin, DeclarationBuiltin, ScopeId,
    ScopeKind,
};

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

pub struct MutableGlobal {
    pub name: CompactString,
}

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

    fn message(&self) -> String {
        format!(
            "global variable `{}` is written more than once; make it readonly or keep later writes local",
            self.name
        )
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum GlobalWriteKind {
    File,
    Function,
}

#[derive(Debug, Clone, Copy)]
struct GlobalWrite {
    binding_id: BindingId,
    kind: GlobalWriteKind,
}

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

    let semantic = checker.semantic();
    let mut writes_by_name = FxHashMap::<Name, Vec<GlobalWrite>>::default();

    for binding in semantic.bindings() {
        if !is_mutable_global_write_candidate(binding) {
            continue;
        }

        let Some(kind) = global_write_kind(checker, binding) else {
            continue;
        };

        writes_by_name
            .entry(binding.name.clone())
            .or_default()
            .push(GlobalWrite {
                binding_id: binding.id,
                kind,
            });
    }

    let allow_conditional_init = checker.rule_options().c159.allow_conditional_init;
    let mut reported = FxHashSet::<BindingId>::default();
    let mut reportable_bindings = Vec::new();

    for writes in writes_by_name.values_mut() {
        writes.sort_unstable_by_key(|write| semantic.binding(write.binding_id).span.start.offset());

        let Some(first_file_write) = writes
            .iter()
            .find(|write| write.kind == GlobalWriteKind::File)
            .map(|write| write.binding_id)
        else {
            continue;
        };

        for write in writes.iter().copied() {
            let binding = semantic.binding(write.binding_id);

            if write.kind == GlobalWriteKind::File && write.binding_id == first_file_write {
                continue;
            }
            if allow_conditional_init && is_conditional_default_init(binding, checker) {
                continue;
            }
            if reported.insert(write.binding_id) {
                reportable_bindings.push(write.binding_id);
            }
        }
    }

    reportable_bindings
        .sort_unstable_by_key(|binding_id| semantic.binding(*binding_id).span.start.offset());

    for binding_id in reportable_bindings {
        let binding = semantic.binding(binding_id);
        checker.report(
            MutableGlobal {
                name: binding.name.as_str().into(),
            },
            report_span_for_binding(binding),
        );
    }
}

fn global_write_kind(checker: &Checker<'_>, binding: &Binding) -> Option<GlobalWriteKind> {
    let semantic = checker.semantic();

    if matches!(semantic.scope_kind(binding.scope), ScopeKind::File) {
        return Some(GlobalWriteKind::File);
    }

    if checker
        .semantic_analysis()
        .scope_runs_in_transient_context(binding.scope)
    {
        return None;
    }

    let function_scope =
        semantic.enclosing_function_scope_without_transient_boundary(binding.scope)?;
    if binding.attributes.contains(BindingAttributes::LOCAL)
        || has_prior_function_declaration(checker, binding, function_scope)
    {
        return None;
    }

    Some(GlobalWriteKind::Function)
}

fn has_prior_function_declaration(
    checker: &Checker<'_>,
    binding: &Binding,
    function_scope: ScopeId,
) -> bool {
    let semantic = checker.semantic();
    semantic
        .bindings_for(&binding.name)
        .iter()
        .copied()
        .any(|id| {
            let candidate = semantic.binding(id);
            candidate.scope == function_scope
                && candidate.span.start.offset() < binding.span.start.offset()
                && is_function_local_declaration(candidate)
                && declaration_runs_before_binding(checker, candidate, binding, function_scope)
        })
}

fn declaration_runs_before_binding(
    checker: &Checker<'_>,
    declaration: &Binding,
    binding: &Binding,
    function_scope: ScopeId,
) -> bool {
    let analysis = checker.semantic_analysis();
    let declaration_blocks = analysis.reachable_blocks_for_binding(declaration.id);
    let binding_blocks = analysis.reachable_blocks_for_binding(binding.id);
    if declaration_blocks.is_empty() {
        return false;
    }
    if binding_blocks.is_empty() {
        return true;
    }

    let declaration_blocks = declaration_blocks.into_iter().collect::<FxHashSet<_>>();
    let uncovered_binding_blocks = binding_blocks
        .into_iter()
        .filter(|block| !declaration_blocks.contains(block))
        .collect::<Vec<_>>();
    if uncovered_binding_blocks.is_empty() {
        return true;
    }

    let Some(entry_block) = analysis.cfg().scope_entry(function_scope) else {
        return true;
    };

    !analysis.blocks_have_path_avoiding(
        &[entry_block],
        &uncovered_binding_blocks,
        &declaration_blocks,
    )
}

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

fn is_mutable_global_write_candidate(binding: &Binding) -> bool {
    if binding
        .attributes
        .intersects(BindingAttributes::READONLY | BindingAttributes::LOCAL)
    {
        return false;
    }

    match binding.kind {
        BindingKind::Assignment
        | BindingKind::AppendAssignment
        | BindingKind::ArrayAssignment
        | BindingKind::LoopVariable
        | BindingKind::ReadTarget
        | BindingKind::MapfileTarget
        | BindingKind::PrintfTarget
        | BindingKind::GetoptsTarget
        | BindingKind::ZparseoptsTarget
        | BindingKind::ArithmeticAssignment
        | BindingKind::ParameterDefaultAssignment => true,
        BindingKind::Declaration(_) => binding
            .attributes
            .contains(BindingAttributes::DECLARATION_INITIALIZED),
        BindingKind::FunctionDefinition | BindingKind::Imported | BindingKind::Nameref => false,
    }
}

fn is_conditional_default_init(binding: &Binding, checker: &Checker<'_>) -> bool {
    if matches!(binding.kind, BindingKind::ParameterDefaultAssignment) {
        return true;
    }

    binding
        .attributes
        .contains(BindingAttributes::SELF_REFERENTIAL_READ)
        && matches!(binding.origin, BindingOrigin::Assignment { .. })
        && checker
            .facts()
            .assignments()
            .binding_value(binding.id)
            .is_some_and(|value| value.uses_only_self_default_parameter_expansions())
}

fn report_span_for_binding(binding: &Binding) -> Span {
    match binding.origin {
        BindingOrigin::LoopVariable {
            definition_span, ..
        }
        | BindingOrigin::Assignment {
            definition_span, ..
        }
        | BindingOrigin::ParameterDefaultAssignment {
            definition_span, ..
        }
        | BindingOrigin::Imported { definition_span }
        | BindingOrigin::FunctionDefinition { definition_span }
        | BindingOrigin::BuiltinTarget {
            definition_span, ..
        }
        | BindingOrigin::Declaration { definition_span }
        | BindingOrigin::Nameref { definition_span } => definition_span,
        BindingOrigin::ArithmeticAssignment { target_span, .. } => target_span,
    }
}

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

    #[test]
    fn reports_repeated_top_level_assignments_and_function_reassignments() {
        let source = "\
#!/bin/bash
count=0
count=1
refresh() {
  count=2
}
refresh
";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MutableGlobal));

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

    #[test]
    fn ignores_single_top_level_assignments() {
        let source = "#!/bin/bash\ncount=0\necho \"$count\"\n";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MutableGlobal));

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn ignores_readonly_globals() {
        let source = "\
#!/bin/bash
readonly COUNT=0
declare -r NAME=demo
show() {
  echo \"$COUNT\" \"$NAME\"
}
";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MutableGlobal));

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn ignores_function_local_shadowing() {
        let source = "\
#!/bin/bash
value=0
update() {
  local value
  value=1
}
";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MutableGlobal));

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn reports_function_assignment_when_prior_local_declaration_is_not_guaranteed() {
        let source = "\
#!/bin/bash
value=0
update() {
  if [[ $make_local ]]; then
    local value
  fi
  value=1
}
";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MutableGlobal));

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

    #[test]
    fn reports_function_assignment_when_prior_local_declaration_is_unreachable() {
        let source = "\
#!/bin/bash
value=0
update() {
  if [[ $stop ]]; then
    return
    local value
  fi
  value=1
}
";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MutableGlobal));

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

    #[test]
    fn ignores_branch_local_assignment_after_branch_local_declaration() {
        let source = "\
#!/bin/bash
value=0
update() {
  if [[ $make_local ]]; then
    local value
    value=1
  fi
}
";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MutableGlobal));

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

    #[test]
    fn allows_conditional_default_initializers_by_default() {
        let source = "\
#!/bin/bash
mode=dev
mode=${mode:-prod}
";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MutableGlobal));

        assert!(diagnostics.is_empty());
    }

    #[test]
    fn reports_self_referential_parameter_rewrites() {
        let source = "\
#!/bin/bash
mode=dev
mode=${mode#dev}
path=${path:-/tmp}
path=${path%/}
";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MutableGlobal));

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

    #[test]
    fn reports_mixed_self_default_and_rewrite_assignments() {
        let source = "\
#!/bin/bash
mode=dev
mode=${mode#d}${mode:-prod}
path=old
path=${path:-/tmp}${other:-$path}
";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MutableGlobal));

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

    #[test]
    fn reports_arithmetic_self_reads_after_conditional_defaults() {
        let source = "\
#!/bin/bash
mode=dev
mode=${mode:-prod}$((mode + 1))
";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MutableGlobal));

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

    #[test]
    fn reports_substitution_assignment_self_reads_after_conditional_defaults() {
        let source = "\
#!/bin/bash
mode=dev
mode=${mode:-prod}$(mode=$mode; printf x)
";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MutableGlobal));

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

    #[test]
    fn ignores_default_operators_in_trailing_comments() {
        let source = "\
#!/bin/bash
mode=dev
mode=${mode#dev} # ${mode:-prod}
";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MutableGlobal));

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

    #[test]
    fn allows_conditional_default_initializers_after_quoted_or_substitution_semicolons() {
        let source = "\
#!/bin/bash
mode=dev
mode=\"x;${mode:-prod}\"
path=old
path=\"$(printf '%s;' value)${path:-/tmp}\"
";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MutableGlobal));

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

    #[test]
    fn still_ignores_default_operators_after_real_assignment_terminators() {
        let source = "\
#!/bin/bash
mode=dev
mode=${mode#dev}; echo ${mode:-prod}
";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MutableGlobal));

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

    #[test]
    fn option_can_report_conditional_default_initializers() {
        let source = "\
#!/bin/bash
mode=dev
mode=${mode:-prod}
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::MutableGlobal).with_c159_allow_conditional_init(false),
        );

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].span.slice(source), "mode");
    }

    #[test]
    fn later_assignment_after_conditional_baseline_is_reported() {
        let source = "\
#!/bin/bash
mode=${mode:-dev}
mode=prod
";
        let diagnostics = test_snippet(source, &LinterSettings::for_rule(Rule::MutableGlobal));

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

    #[test]
    fn ignores_other_shells() {
        let source = "\
#!/bin/sh
count=0
count=1
";
        let diagnostics = test_snippet(
            source,
            &LinterSettings::for_rule(Rule::MutableGlobal).with_shell(ShellDialect::Sh),
        );

        assert!(diagnostics.is_empty());
    }
}
