import crypto from 'crypto';
import path from 'path';
import { Plugin } from 'vite';
import { bundleConstants, generateExposedDependenciesMetadata, sortSharedCss } from '../../../core';
import { assembleIife, Expose, ModuleFactory } from './assemble-iife';
import { assertNoDynamicCss } from './assert-no-dynamic-css';
import { flatten } from './flatten';

export interface SharedDependenciesPluginOptions {
    dependencies: Record<string, string>;
    css?: Record<string, string[]>;
    metadataDir?: string;
}

// Suffix for the CJS entry files emitted by `entryFileNames` below.
const ENTRY_FILE_SUFFIX = '.cjs.js';

export function sharedDependenciesPlugin({
    dependencies,
    css,
    metadataDir,
}: SharedDependenciesPluginOptions): Plugin {
    const CSS_VIRTUAL_PREFIX = '\0shared-dependencies:css:';
    const CSS_VIRTUAL_ID_PATTERN = new RegExp(`^${CSS_VIRTUAL_PREFIX}`);

    const dependencyNames = Object.keys(dependencies);
    const dependencySet = new Set(dependencyNames);
    const cssEntries = css ?? {};
    const cssInputNames = Object.keys(cssEntries);
    let isProduction = false;

    return {
        name: 'st:shared-dependencies',
        enforce: 'pre',

        config(_, env) {
            isProduction = env.mode === bundleConstants.mode.production;

            const input: Record<string, string> = {};
            for (const dependency of dependencyNames) {
                input[flatten(dependency)] = dependency;
            }
            for (const name of cssInputNames) {
                input[name] = CSS_VIRTUAL_PREFIX + name;
            }

            return {
                // Relative base so CSS font URLs resolve from the CSS file's directory, not site root
                base: './',
                // Must match host build mode; mismatch causes react-dom/client usingClientEntryPoint errors
                define: {
                    'process.env.NODE_ENV': JSON.stringify(env.mode),
                },
                build: {
                    rolldownOptions: {
                        // Vite defaults to false which strips all CJS exports from entry chunks
                        preserveEntrySignatures: 'exports-only',
                        input,
                        output: {
                            /*
                             * CJS format + named exports: the factory system wraps each entry's
                             * code in function(module, exports, require) { ... }
                             */
                            format: 'cjs',
                            exports: 'named',
                            entryFileNames: `[name]${ENTRY_FILE_SUFFIX}`,
                            chunkFileNames: '_[name]-[hash].js',
                            assetFileNames: assetInfo => {
                                const rawName = assetInfo.names?.[0] ?? '';
                                const ext = rawName.split('.').pop() ?? '';
                                // Strip scope prefix added by flatten (e.g. @servicetitan-anvil2 to anvil2)
                                const name = rawName
                                    .replace(/^@[^-]+-/, '')
                                    .replace(/\.[^.]+$/, '');
                                if (ext === 'css') {
                                    return isProduction
                                        ? `${name}.[hash].bundle[extname]`
                                        : `${name}.bundle[extname]`;
                                }
                                return isProduction
                                    ? `${name}.[hash][extname]`
                                    : `${name}[extname]`;
                            },
                        },
                        // Disabled: we don't know which exports MFEs will use at runtime
                        treeshake: false,
                    },
                },
            };
        },

        resolveId(source: string, importer: string | undefined) {
            if (source.startsWith(CSS_VIRTUAL_PREFIX)) {
                return source;
            }
            if (!importer) {
                return;
            }
            if (dependencySet.has(source)) {
                return { id: source, external: true };
            }
        },

        load: {
            filter: { id: CSS_VIRTUAL_ID_PATTERN },
            handler(id: string) {
                const name = id.slice(CSS_VIRTUAL_PREFIX.length);
                const imports = cssEntries[name];
                if (!imports) {
                    return;
                }
                return imports.map(p => `import '${p}';`).join('\n');
            },
        },

        generateBundle(_options, bundle) {
            assertNoDynamicCss(bundle);

            const modules: ModuleFactory[] = [];
            const exposes: Expose[] = [];

            for (const [fileName, chunk] of Object.entries(bundle)) {
                if (chunk.type !== 'chunk') {
                    continue;
                }
                if (!chunk.isEntry) {
                    modules.push({ id: `./${fileName}`, code: chunk.code });
                    delete bundle[fileName];
                }
            }

            for (const [dependency, variable] of Object.entries(dependencies)) {
                const entryFileName = `${flatten(dependency)}${ENTRY_FILE_SUFFIX}`;
                const chunk = bundle[entryFileName];
                if (chunk?.type !== 'chunk') {
                    throw new Error(
                        `Expected entry chunk for ${dependency}, got type: ${chunk?.type ?? 'undefined'}`
                    );
                }
                modules.push({ id: dependency, code: chunk.code });
                exposes.push({ variable, dependency });
                delete bundle[entryFileName];
            }

            for (const name of cssInputNames) {
                delete bundle[`${name}${ENTRY_FILE_SUFFIX}`];
            }

            const code = assembleIife(modules, exposes);
            const hash = crypto.createHash('md5').update(code).digest('hex').slice(0, 20);
            const sharedFileName = isProduction ? `shared.${hash}.bundle.js` : 'shared.bundle.js';

            this.emitFile({
                type: 'asset',
                fileName: sharedFileName,
                source: code,
            });

            const cssFiles = sortSharedCss(
                Object.keys(bundle).filter(f => f.endsWith('.bundle.css'))
            );
            const entrypoints = { css: cssFiles, js: [sharedFileName] };
            this.emitFile({
                type: 'asset',
                fileName: 'entrypoints.json',
                source: JSON.stringify(entrypoints, null, 2),
            });
        },

        writeBundle: metadataDir
            ? options => {
                  /*
                   * dir is always set when build.outDir is configured (our case).
                   * Rolldown types it as optional, but falling back to '' would
                   * silently resolve to cwd; better to crash than write wrong metadata.
                   */
                  generateExposedDependenciesMetadata(metadataDir, () =>
                      path.resolve(options.dir!, 'entrypoints.json')
                  );
              }
            : undefined,
    };
}
