use std::path::{Path, PathBuf};

use pathdiff::diff_paths;
use swc_core::{
    common::{errors::HANDLER, FileName, Span, DUMMY_SP},
    ecma::{
        ast::{
            ArrayLit, ArrowExpr, BlockStmtOrExpr, Bool, CallExpr, Callee, Expr, ExprOrSpread, Id,
            Ident, ImportDecl, ImportDefaultSpecifier, ImportNamedSpecifier, ImportSpecifier,
            KeyValueProp, Lit, MemberExpr, MemberProp, ModuleDecl, ModuleItem, Null, ObjectLit,
            Prop, PropName, PropOrSpread, Str, Tpl,
        },
        atoms::js_word,
        utils::{private_ident, ExprFactory},
        visit::{Fold, FoldWith},
    },
    quote,
};

pub fn next_dynamic(
    is_development: bool,
    is_server: bool,
    is_server_components: bool,
    mode: NextDynamicMode,
    filename: FileName,
    pages_dir: Option<PathBuf>,
) -> impl Fold {
    NextDynamicPatcher {
        is_development,
        is_server,
        is_server_components,
        mode,
        pages_dir,
        filename,
        dynamic_bindings: vec![],
        is_next_dynamic_first_arg: false,
        dynamically_imported_specifier: None,
        turbo_imports: vec![],
    }
}

#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum NextDynamicMode {
    /// In Webpack mode, each `dynamic()` call will generate a key composed
    /// from:
    /// 1. The current module's path relative to the pages directory;
    /// 2. The relative imported module id.
    ///
    /// This key is of the form:
    /// ```
    /// {currentModulePath} -> {relativeImportedModulePath}
    /// ```
    ///
    /// It corresponds to an entry in the React Loadable Manifest generated by
    /// the React Loadable Webpack plugin.
    Webpack,
    /// In Turbo mode, each `dynamic()` call will generate a key containing both
    /// the imported module id and the chunks it needs. This removes the need
    /// for a manifest entry.
    Turbo,
}

#[derive(Debug)]
struct NextDynamicPatcher {
    is_development: bool,
    is_server: bool,
    is_server_components: bool,
    /// Which mode to run the transform under.
    mode: NextDynamicMode,
    pages_dir: Option<PathBuf>,
    filename: FileName,
    dynamic_bindings: Vec<Id>,
    is_next_dynamic_first_arg: bool,
    dynamically_imported_specifier: Option<(String, Span)>,
    /// In Turbo mode, contains a list of modules that need to be imported with
    /// the `next-client-chunks` transition under a particular ident.
    turbo_imports: Vec<TurboImport>,
}

#[derive(Debug)]
enum TurboImport {
    WithTransition {
        id_ident: Ident,
        chunks_ident: Ident,
        specifier: String,
    },
    WithId {
        id_ident: Ident,
        specifier: String,
    },
}

impl Fold for NextDynamicPatcher {
    fn fold_module_items(&mut self, mut items: Vec<ModuleItem>) -> Vec<ModuleItem> {
        items = items.fold_children_with(self);

        self.maybe_add_dynamically_imported_specifier(&mut items);

        items
    }

    fn fold_import_decl(&mut self, decl: ImportDecl) -> ImportDecl {
        let ImportDecl {
            ref src,
            ref specifiers,
            ..
        } = decl;
        if &src.value == "next/dynamic" {
            for specifier in specifiers {
                if let ImportSpecifier::Default(default_specifier) = specifier {
                    self.dynamic_bindings.push(default_specifier.local.to_id());
                }
            }
        }

        decl
    }

    fn fold_call_expr(&mut self, expr: CallExpr) -> CallExpr {
        if self.is_next_dynamic_first_arg {
            if let Callee::Import(..) = &expr.callee {
                match &*expr.args[0].expr {
                    Expr::Lit(Lit::Str(Str { value, span, .. })) => {
                        self.dynamically_imported_specifier = Some((value.to_string(), *span));
                    }
                    Expr::Tpl(Tpl { exprs, quasis, .. }) if exprs.is_empty() => {
                        self.dynamically_imported_specifier =
                            Some((quasis[0].raw.to_string(), quasis[0].span));
                    }
                    _ => {}
                }
            }
            return expr.fold_children_with(self);
        }
        let mut expr = expr.fold_children_with(self);
        if let Callee::Expr(i) = &expr.callee {
            if let Expr::Ident(identifier) = &**i {
                if self.dynamic_bindings.contains(&identifier.to_id()) {
                    if expr.args.is_empty() {
                        HANDLER.with(|handler| {
                            handler
                                .struct_span_err(
                                    identifier.span,
                                    "next/dynamic requires at least one argument",
                                )
                                .emit()
                        });
                        return expr;
                    } else if expr.args.len() > 2 {
                        HANDLER.with(|handler| {
                            handler
                                .struct_span_err(
                                    identifier.span,
                                    "next/dynamic only accepts 2 arguments",
                                )
                                .emit()
                        });
                        return expr;
                    }
                    if expr.args.len() == 2 {
                        match &*expr.args[1].expr {
                            Expr::Object(_) => {}
                            _ => {
                                HANDLER.with(|handler| {
                          handler
                              .struct_span_err(
                                  identifier.span,
                                  "next/dynamic options must be an object literal.\nRead more: https://nextjs.org/docs/messages/invalid-dynamic-options-type",
                              )
                              .emit();
                      });
                                return expr;
                            }
                        }
                    }

                    self.is_next_dynamic_first_arg = true;
                    expr.args[0].expr = expr.args[0].expr.clone().fold_with(self);
                    self.is_next_dynamic_first_arg = false;

                    let Some((dynamically_imported_specifier, dynamically_imported_specifier_span)) = self.dynamically_imported_specifier.take() else {
                        return expr;
                    };

                    // dev client or server:
                    // loadableGenerated: {
                    //   modules:
                    // ["/project/src/file-being-transformed.js -> " + '../components/hello'] }

                    // prod client
                    // loadableGenerated: {
                    //   webpack: () => [require.resolveWeak('../components/hello')],
                    let generated = Box::new(Expr::Object(ObjectLit {
                        span: DUMMY_SP,
                        props: if self.is_development || self.is_server {
                            let module_id = match self.mode {
                                NextDynamicMode::Webpack => quote!(
                                    "$left + $right" as Expr,
                                    left: Expr = format!(
                                        "{} -> ",
                                        rel_filename(self.pages_dir.as_deref(), &self.filename)
                                    )
                                    .into(),
                                    right: Expr = dynamically_imported_specifier.into(),
                                ),
                                NextDynamicMode::Turbo => {
                                    let id_ident =
                                        private_ident!(dynamically_imported_specifier_span, "id");

                                    if self.is_server {
                                        let chunks_ident = private_ident!(
                                            dynamically_imported_specifier_span,
                                            "chunks"
                                        );

                                        self.turbo_imports.push(TurboImport::WithTransition {
                                            id_ident: id_ident.clone(),
                                            chunks_ident: chunks_ident.clone(),
                                            specifier: dynamically_imported_specifier,
                                        });

                                        // On the server, the key needs to be serialized because it
                                        // will be used to index the React Loadable Manifest, which
                                        // is a normal JS object. In Turbo mode, this is a proxy,
                                        // but the key will still be coerced to a string.
                                        quote!(
                                            r#"
                                            JSON.stringify({
                                                id: $id,
                                                chunks: $chunks
                                            })
                                            "# as Expr,
                                            id = id_ident,
                                            chunks = chunks_ident,
                                        )
                                    } else {
                                        self.turbo_imports.push(TurboImport::WithId {
                                            id_ident: id_ident.clone(),
                                            specifier: dynamically_imported_specifier,
                                        });

                                        // On the client, we only need the target module ID, which
                                        // will be reported under the `dynamicIds` property of Next
                                        // data.
                                        Expr::Ident(id_ident)
                                    }
                                }
                            };

                            vec![PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp {
                                key: PropName::Ident(Ident::new("modules".into(), DUMMY_SP)),
                                value: Box::new(Expr::Array(ArrayLit {
                                    elems: vec![Some(ExprOrSpread {
                                        expr: Box::new(module_id),
                                        spread: None,
                                    })],
                                    span: DUMMY_SP,
                                })),
                            })))]
                        } else {
                            vec![PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp {
                                key: PropName::Ident(Ident::new("webpack".into(), DUMMY_SP)),
                                value: Box::new(Expr::Arrow(ArrowExpr {
                                    params: vec![],
                                    body: BlockStmtOrExpr::Expr(Box::new(Expr::Array(ArrayLit {
                                        elems: vec![Some(ExprOrSpread {
                                            expr: Box::new(Expr::Call(CallExpr {
                                                callee: Callee::Expr(Box::new(Expr::Member(
                                                    MemberExpr {
                                                        obj: Box::new(Expr::Ident(Ident {
                                                            sym: js_word!("require"),
                                                            span: DUMMY_SP,
                                                            optional: false,
                                                        })),
                                                        prop: MemberProp::Ident(Ident {
                                                            sym: "resolveWeak".into(),
                                                            span: DUMMY_SP,
                                                            optional: false,
                                                        }),
                                                        span: DUMMY_SP,
                                                    },
                                                ))),
                                                args: vec![ExprOrSpread {
                                                    expr: Box::new(Expr::Lit(Lit::Str(Str {
                                                        value: dynamically_imported_specifier
                                                            .into(),
                                                        span: DUMMY_SP,
                                                        raw: None,
                                                    }))),
                                                    spread: None,
                                                }],
                                                span: DUMMY_SP,
                                                type_args: None,
                                            })),
                                            spread: None,
                                        })],
                                        span: DUMMY_SP,
                                    }))),
                                    is_async: false,
                                    is_generator: false,
                                    span: DUMMY_SP,
                                    return_type: None,
                                    type_params: None,
                                })),
                            })))]
                        },
                    }));

                    let mut props =
                        vec![PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp {
                            key: PropName::Ident(Ident::new("loadableGenerated".into(), DUMMY_SP)),
                            value: generated,
                        })))];

                    let mut has_ssr_false = false;
                    let mut has_suspense = false;

                    if expr.args.len() == 2 {
                        if let Expr::Object(ObjectLit {
                            props: options_props,
                            ..
                        }) = &*expr.args[1].expr
                        {
                            for prop in options_props.iter() {
                                if let Some(KeyValueProp { key, value }) = match prop {
                                    PropOrSpread::Prop(prop) => match &**prop {
                                        Prop::KeyValue(key_value_prop) => Some(key_value_prop),
                                        _ => None,
                                    },
                                    _ => None,
                                } {
                                    if let Some(Ident {
                                        sym,
                                        span: _,
                                        optional: _,
                                    }) = match key {
                                        PropName::Ident(ident) => Some(ident),
                                        _ => None,
                                    } {
                                        if sym == "ssr" {
                                            if let Some(Lit::Bool(Bool {
                                                value: false,
                                                span: _,
                                            })) = value.as_lit()
                                            {
                                                has_ssr_false = true
                                            }
                                        }
                                        if sym == "suspense" {
                                            if let Some(Lit::Bool(Bool {
                                                value: true,
                                                span: _,
                                            })) = value.as_lit()
                                            {
                                                has_suspense = true
                                            }
                                        }
                                    }
                                }
                            }
                            props.extend(options_props.iter().cloned());
                        }
                    }

                    // Don't strip the `loader` argument if suspense is true
                    // See https://github.com/vercel/next.js/issues/36636 for background.

                    // Also don't strip the `loader` argument for server components (both
                    // server/client layers), since they're aliased to a
                    // React.lazy implementation.
                    if has_ssr_false
                        && !has_suspense
                        && self.is_server
                        && !self.is_server_components
                    {
                        expr.args[0] = Lit::Null(Null { span: DUMMY_SP }).as_arg();
                    }

                    let second_arg = ExprOrSpread {
                        spread: None,
                        expr: Box::new(Expr::Object(ObjectLit {
                            span: DUMMY_SP,
                            props,
                        })),
                    };

                    if expr.args.len() == 2 {
                        expr.args[1] = second_arg;
                    } else {
                        expr.args.push(second_arg)
                    }
                }
            }
        }
        expr
    }
}

impl NextDynamicPatcher {
    fn maybe_add_dynamically_imported_specifier(&mut self, items: &mut Vec<ModuleItem>) {
        let mut new_items = Vec::with_capacity(self.turbo_imports.len() * 2);

        for import in std::mem::take(&mut self.turbo_imports) {
            match import {
                TurboImport::WithTransition {
                    id_ident,
                    chunks_ident,
                    specifier,
                } => {
                    // The `next-client-chunks` transition will return both the target module's id
                    // and the chunks it needs to run.
                    new_items.push(quote!(
                        "\"TURBOPACK { transition: next-client-chunks }\";" as ModuleItem
                    ));
                    new_items.push(ModuleItem::ModuleDecl(ModuleDecl::Import(ImportDecl {
                        span: DUMMY_SP,
                        specifiers: vec![
                            ImportSpecifier::Default(ImportDefaultSpecifier {
                                span: DUMMY_SP,
                                local: id_ident,
                            }),
                            ImportSpecifier::Named(ImportNamedSpecifier {
                                span: DUMMY_SP,
                                local: chunks_ident,
                                imported: Some(Ident::new("chunks".into(), DUMMY_SP).into()),
                                is_type_only: false,
                            }),
                        ],
                        src: Box::new(specifier.into()),
                        type_only: false,
                        asserts: None,
                    })));
                }
                TurboImport::WithId {
                    id_ident,
                    specifier,
                } => {
                    // We don't want this import to cause the imported module to be considered for
                    // chunking through this import; we only need the module id.
                    new_items.push(quote!(
                        "\"TURBOPACK { chunking-type: none }\";" as ModuleItem
                    ));
                    // Turbopack will automatically transform the imported `__turbopack_module_id__`
                    // identifier into the imported module's id.
                    new_items.push(ModuleItem::ModuleDecl(ModuleDecl::Import(ImportDecl {
                        span: DUMMY_SP,
                        specifiers: vec![ImportSpecifier::Named(ImportNamedSpecifier {
                            span: DUMMY_SP,
                            local: id_ident,
                            imported: Some(
                                Ident::new("__turbopack_module_id__".into(), DUMMY_SP).into(),
                            ),
                            is_type_only: false,
                        })],
                        src: Box::new(specifier.into()),
                        type_only: false,
                        asserts: None,
                    })));
                }
            }
        }

        new_items.append(items);

        std::mem::swap(&mut new_items, items)
    }
}

fn rel_filename(base: Option<&Path>, file: &FileName) -> String {
    let base = match base {
        Some(v) => v,
        None => return file.to_string(),
    };

    let file = match file {
        FileName::Real(v) => v,
        _ => {
            return file.to_string();
        }
    };

    let rel_path = diff_paths(file, base);

    let rel_path = match rel_path {
        Some(v) => v,
        None => return file.display().to_string(),
    };

    rel_path.display().to_string()
}
