use biome_analyze::{
    AddVisitor, Phases, QueryMatch, Queryable, Rule, RuleDiagnostic, RuleDomain, RuleSource,
    ServiceBag, Visitor, context::RuleContext, declare_lint_rule,
};
use biome_console::markup;
use biome_diagnostics::Severity;
use biome_js_syntax::{
    AnyJsExpression, AnyJsRoot, JsAssignmentExpression, JsCallExpression, JsExport, JsLanguage,
    assign_ext::AnyJsMemberAssignment,
};
use biome_rowan::{AstNode, Language, TextRange, WalkEvent, declare_node_union};
use biome_rule_options::no_exports_in_test::NoExportsInTestOptions;

declare_lint_rule! {
    /// Disallow using `export` or `module.exports` in files containing tests
    ///
    /// This rule aims to eliminate duplicate runs of tests by exporting things from test files.
    /// If you import from a test file, then all the tests in that file will be run in each imported instance,
    /// so bottom line, don't export from a test, but instead move helper functions into a separate file when they need to be shared across tests.
    ///
    /// ## Examples
    ///
    /// ### Invalid
    ///
    /// ```js,expect_diagnostic
    /// export function myHelper() {}
    /// describe('a test', () => {
    ///     expect(1).toBe(1);
    /// });
    /// ```
    ///
    /// ### Valid
    ///
    /// ```js
    /// function myHelper() {}
    /// describe('a test', () => {
    ///     expect(1).toBe(1);
    /// });
    /// ```
    ///
    pub NoExportsInTest {
        version: "1.6.0",
        name: "noExportsInTest",
        language: "js",
        recommended: true,
        severity: Severity::Error,
        sources: &[RuleSource::EslintJest("no-export").inspired()],
        domains: &[RuleDomain::Test],
    }
}

declare_node_union! {
    pub MaybeExport = JsExport | JsAssignmentExpression
}

impl MaybeExport {
    fn is_export(&self) -> bool {
        match self {
            Self::JsExport(_) => true,
            Self::JsAssignmentExpression(assignment_expr) => {
                let left = assignment_expr.left().ok();
                left.and_then(|left| AnyJsMemberAssignment::cast(left.into_syntax()))
                    .is_some_and(|member_expr| {
                        let object = member_expr.object().ok();
                        object.is_some_and(|object| match object {
                            AnyJsExpression::JsIdentifierExpression(ident) => match member_expr {
                                AnyJsMemberAssignment::JsComputedMemberAssignment(_) => false,
                                AnyJsMemberAssignment::JsStaticMemberAssignment(static_member) => {
                                    // module.exports = {}
                                    let indent_text = ident.to_trimmed_text();
                                    let member_text = static_member
                                        .member()
                                        .map(|member| member.to_trimmed_text());
                                    indent_text.text() == "module"
                                        && member_text.is_ok_and(|member_text| {
                                            member_text.text() == "exports"
                                        })
                                }
                            },
                            AnyJsExpression::JsStaticMemberExpression(member_expr) => {
                                // modules.exports.foo = {}, module.exports[foo] = {}
                                let object_text =
                                    member_expr.object().map(|object| object.to_trimmed_text());
                                let member_text =
                                    member_expr.member().map(|member| member.to_trimmed_text());
                                object_text.is_ok_and(|text| text.text() == "module")
                                    && member_text
                                        .is_ok_and(|member_text| member_text.text() == "exports")
                            }
                            _ => false,
                        })
                    })
            }
        }
    }
}

#[derive(Default)]
struct AnyExportInTestVisitor {
    has_test: bool,
    exports: Vec<MaybeExport>,
}

impl Visitor for AnyExportInTestVisitor {
    type Language = JsLanguage;

    fn visit(
        &mut self,
        event: &biome_rowan::WalkEvent<biome_rowan::SyntaxNode<Self::Language>>,
        mut ctx: biome_analyze::VisitorContext<Self::Language>,
    ) {
        match event {
            WalkEvent::Enter(node) => {
                if let Some(maybe_export) = MaybeExport::cast_ref(node)
                    && maybe_export.is_export()
                {
                    self.exports.push(maybe_export);
                }

                if !self.has_test
                    && let Some(call_expr) = JsCallExpression::cast_ref(node)
                {
                    self.has_test = call_expr
                        .is_test_call_expression()
                        .ok()
                        .and_then(|is_test_call_expr| {
                            if is_test_call_expr {
                                // Ensure the test call is at the top level to avoid mistakenly identifying files with in-source testing as test files.
                                // Example:
                                // ```js
                                // if (import.meta.vitest) {
                                //   const { describe, expect } = import.meta.vitest;
                                //   describe("a test", () => {
                                //     expect(test).toEqual("");
                                //   });
                                // }
                                // ```
                                // The ancestors of the top-level call expression are:
                                // [JsScript, JsStatementList, JsExpressionStatement, JsCallExpression]
                                // [JsModule, JsModuleItemList, JsExpressionStatement, JsCallExpression]
                                //
                                // **Note**: The ancestors start with the current node.
                                Some(call_expr.syntax().ancestors().count() == 4)
                            } else {
                                None
                            }
                        })
                        .unwrap_or(false);
                }
            }
            WalkEvent::Leave(node) => {
                if AnyJsRoot::cast_ref(node).is_some() && self.has_test {
                    for export in self.exports.iter() {
                        ctx.match_query(AnyExportInTest(export.clone()));
                    }
                }
            }
        }
    }
}

pub struct AnyExportInTest(MaybeExport);

impl QueryMatch for AnyExportInTest {
    fn text_range(&self) -> TextRange {
        self.0.range()
    }
}

impl Queryable for AnyExportInTest {
    type Input = Self;
    type Language = JsLanguage;
    type Output = MaybeExport;
    type Services = ();

    fn build_visitor(
        analyzer: &mut impl AddVisitor<Self::Language>,
        _: &<Self::Language as Language>::Root,
    ) {
        analyzer.add_visitor(Phases::Syntax, AnyExportInTestVisitor::default);
    }

    fn unwrap_match(_: &ServiceBag, query: &Self::Input) -> Self::Output {
        query.0.clone()
    }
}

impl Rule for NoExportsInTest {
    type Query = AnyExportInTest;
    type State = ();
    type Signals = Option<Self::State>;
    type Options = NoExportsInTestOptions;

    fn run(_: &RuleContext<Self>) -> Self::Signals {
        Some(())
    }

    fn diagnostic(ctx: &RuleContext<Self>, _: &Self::State) -> Option<RuleDiagnostic> {
        Some(RuleDiagnostic::new(
            rule_category!(),
            ctx.query().range(),
            markup! {
                "Do not export from a test file."
            },
        ))
    }
}
