use oxc_ast::{
    AstKind,
    ast::{AssignmentTarget, ChainElement, Expression, StaticMemberExpression},
};
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_semantic::SymbolId;
use oxc_span::{GetSpan, Span};

use crate::{
    AstNode,
    context::LintContext,
    rule::Rule,
    utils::{is_this_object, is_vue_component_options_object},
};

fn require_slots_as_functions_diagnostic(span: Span) -> OxcDiagnostic {
    OxcDiagnostic::warn("Property in `$slots` should be used as function.").with_label(span)
}

#[derive(Debug, Default, Clone)]
pub struct RequireSlotsAsFunctions;

declare_oxc_lint!(
    /// ### What it does
    ///
    /// Enforce properties of `$slots` to be used as functions.
    ///
    /// ### Why is this bad?
    ///
    /// In Vue.js 3, `this.$slots.<name>` is a function (slot render function),
    /// not an array of vnodes like in Vue.js 2. Treating slot properties as
    /// values (e.g. `this.$slots.default.filter(...)`) breaks at runtime.
    ///
    /// ### Examples
    ///
    /// Examples of **incorrect** code for this rule:
    /// ```vue
    /// <script>
    /// export default {
    ///   render(h) {
    ///     var children = this.$slots.default
    ///     return h('div', children.filter(...))
    ///   }
    /// }
    /// </script>
    /// ```
    ///
    /// Examples of **correct** code for this rule:
    /// ```vue
    /// <script>
    /// export default {
    ///   render(h) {
    ///     var children = this.$slots.default()
    ///     return h('div', children)
    ///   }
    /// }
    /// </script>
    /// ```
    RequireSlotsAsFunctions,
    vue,
    correctness,
    version = "1.67.0",
    short_description = "Enforce properties of `$slots` to be used as functions.",
);

impl Rule for RequireSlotsAsFunctions {
    fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
        let AstKind::StaticMemberExpression(slot_prop_member) = node.kind() else { return };

        // Inner must be `this.$slots` (`StaticMemberExpression` with property `$slots`).
        let Some(slots_static) = inner_static_member_expression(&slot_prop_member.object) else {
            return;
        };
        if slots_static.property.name != "$slots" {
            return;
        }
        if !is_under_vue_component_options_object(node, ctx) {
            return;
        }
        if !is_this_object(&slots_static.object, ctx) {
            return;
        }

        verify(node, slot_prop_member.property.span, ctx);
    }

    fn should_run(&self, ctx: &crate::context::ContextHost) -> bool {
        ctx.file_extension().is_some_and(|ext| ext == "vue")
    }
}

fn inner_static_member_expression<'a, 'b>(
    expr: &'b Expression<'a>,
) -> Option<&'b StaticMemberExpression<'a>> {
    match expr.get_inner_expression() {
        Expression::StaticMemberExpression(m) => Some(m),
        Expression::ChainExpression(chain) => match &chain.expression {
            ChainElement::StaticMemberExpression(m) => Some(m),
            _ => None,
        },
        _ => None,
    }
}

fn verify(node: &AstNode<'_>, report_span: Span, ctx: &LintContext<'_>) {
    let parent = ctx.nodes().parent_node(node.id());
    match parent.kind() {
        // `this.$slots.foo()` — used as a function, OK
        AstKind::CallExpression(call) if call.callee.span() == node.kind().span() => {}
        // `(this.$slots.foo)?.bar` etc. — keep walking up
        AstKind::ChainExpression(_)
        | AstKind::ParenthesizedExpression(_)
        | AstKind::TSAsExpression(_)
        | AstKind::TSNonNullExpression(_)
        | AstKind::TSSatisfiesExpression(_) => {
            verify(parent, report_span, ctx);
        }
        // `var children = this.$slots.foo` — follow references of `children`
        AstKind::VariableDeclarator(var)
            if var.init.as_ref().is_some_and(|init| init.span() == node.kind().span()) =>
        {
            if let Some(binding_ident) = var.id.get_binding_identifier() {
                let symbol_id = binding_ident.symbol_id();
                follow_references(symbol_id, report_span, ctx);
            }
        }
        // `children = this.$slots.foo` — follow references of `children`
        AstKind::AssignmentExpression(assign) if assign.right.span() == node.kind().span() => {
            if let AssignmentTarget::AssignmentTargetIdentifier(target) = &assign.left
                && let Some(symbol_id) =
                    ctx.scoping().get_reference(target.reference_id()).symbol_id()
            {
                follow_references(symbol_id, report_span, ctx);
            }
        }
        // `.bar` access / spread / array element / non-init declarator → NG
        AstKind::StaticMemberExpression(_)
        | AstKind::ComputedMemberExpression(_)
        | AstKind::PrivateFieldExpression(_)
        | AstKind::SpreadElement(_)
        | AstKind::ArrayExpression(_)
        | AstKind::VariableDeclarator(_) => {
            ctx.diagnostic(require_slots_as_functions_diagnostic(report_span));
        }
        _ => {}
    }
}

fn is_under_vue_component_options_object(node: &AstNode<'_>, ctx: &LintContext<'_>) -> bool {
    ctx.nodes().ancestors(node.id()).any(|ancestor| is_vue_component_options_object(ancestor, ctx))
}

fn follow_references(symbol_id: SymbolId, report_span: Span, ctx: &LintContext<'_>) {
    for reference in ctx.scoping().get_resolved_references(symbol_id) {
        if !reference.flags().is_read() {
            continue;
        }
        let ref_node = ctx.nodes().get_node(reference.node_id());
        verify(ref_node, report_span, ctx);
    }
}

#[test]
fn test() {
    use std::path::PathBuf;

    use crate::tester::Tester;

    let pass = vec![
        (
            "
                  <script>
                  export default {
                    render (h) {
                      var children = this.$slots.default()
                      var children = this.$slots.default && this.$slots.default()

                      return h('div', this.$slots.default)
                    }
                  }
                  </script>
                  ",
            None,
            None,
            Some(PathBuf::from("test.vue")),
        ),
        (
            "
                  <script>
                  export default {
                    render (h) {
                      var children = unknown.$slots.default
                      var children = unknown.$slots.default.filter(test)

                      return h('div', [...children])
                    }
                  }
                  </script>
                  ",
            None,
            None,
            Some(PathBuf::from("test.vue")),
        ),
        (
            "
                  <script>
                  function render() {
                    var children = this.$slots.default
                    var children = this.$slots.default.filter(test)

                    return children
                  }
                  </script>
                  ",
            None,
            None,
            Some(PathBuf::from("test.vue")),
        ),
        (
            "
                  <script>
                  const component = {
                    render (h) {
                      var children = this.$slots.default
                      var children = this.$slots.default.filter(test)

                      return h('div', [...children])
                    }
                  }
                  </script>
                  ",
            None,
            None,
            Some(PathBuf::from("test.vue")),
        ),
        (
            r"
                  <script>
                  export default {
                    render(h) {
                      const { vm } = this
                      return h('div', vm.$slots.default.filter(test))
                    }
                  }
                  </script>
                  ",
            None,
            None,
            Some(PathBuf::from("test.vue")),
        ),
        (
            r"
                  <script>
                  export default {
                    render(h) {
                      let vm = this
                      vm = other
                      return h('div', vm.$slots.default.filter(test))
                    }
                  }
                  </script>
                  ",
            None,
            None,
            Some(PathBuf::from("test.vue")),
        ),
    ];

    let fail = vec![
        (
            "
                  <script>
                  export default {
                    render (h) {
                      var children = this.$slots.default
                      var children = this.$slots.default.filter(test)

                      return h('div', [...children])
                    }
                  }
                  </script>
                  ",
            None,
            None,
            Some(PathBuf::from("test.vue")),
        ),
        (
            "
                  <script>
                  export default {
                    render (h) {
                      var bar = this.$slots.foo?.bar // NG
                      var bar = this.$slots.foo?.() // OK
                      var bar = (this.$slots?.foo)?.bar // NG
                      var bar = (this.$slots?.foo)?.() // OK
                      var bar = (this?.$slots)?.foo?.bar // NG
                      var bar = (this?.$slots)?.foo?.() // OK
                      return h('div', bar)
                    }
                  }
                  </script>
                  ",
            None,
            None,
            Some(PathBuf::from("test.vue")),
        ),
        (
            "
                  <script>
                  Vue.mixin({
                    render (h) {
                      var children = this.$slots.default
                      return h('div', [...children])
                    }
                  })
                  </script>
                  ",
            None,
            None,
            Some(PathBuf::from("test.vue")),
        ),
        (
            r"
                  <script>
                  export default {
                    render(h) {
                      const vm = this
                      return h('div', vm.$slots.default.filter(test))
                    }
                  }
                  </script>
                  ",
            None,
            None,
            Some(PathBuf::from("test.vue")),
        ),
    ];

    Tester::new(RequireSlotsAsFunctions::NAME, RequireSlotsAsFunctions::PLUGIN, pass, fail)
        .test_and_snapshot();
}
