import { debounce } from 'perfect-debounce';
import chokidar from 'chokidar';
import {
  BuildStepOutput,
  EntrypointGroup,
  InlineConfig,
  ServerInfo,
  WxtDevServer,
} from '../types';
import { getEntrypointBundlePath, isHtmlEntrypoint } from './utils/entrypoints';
import {
  getContentScriptCssFiles,
  getContentScriptsCssMap,
} from './utils/manifest';
import {
  internalBuild,
  detectDevChanges,
  rebuild,
  findEntrypoints,
} from './utils/building';
import { createExtensionRunner } from './runners';
import { Mutex } from 'async-mutex';
import pc from 'picocolors';
import { relative } from 'node:path';
import { deinitWxtModules, initWxtModules, registerWxt, wxt } from './wxt';
import { unnormalizePath } from './utils/paths';
import {
  getContentScriptJs,
  mapWxtOptionsToRegisteredContentScript,
} from './utils/content-scripts';
import { createKeyboardShortcuts } from './keyboard-shortcuts';
import { isBabelSyntaxError, logBabelSyntaxError } from './utils/syntax-errors';

/**
 * Creates a dev server and pre-builds all the files that need to exist before loading the extension.
 *
 * @example
 * const server = await wxt.createServer({
 *   // Enter config...
 * });
 * await server.start();
 */
export async function createServer(
  inlineConfig?: InlineConfig,
): Promise<WxtDevServer> {
  await registerWxt('serve', inlineConfig);

  wxt.server = await createServerInternal();
  await wxt.hooks.callHook('server:created', wxt, wxt.server);
  return wxt.server;
}

async function createServerInternal(): Promise<WxtDevServer> {
  const getServerInfo = (): ServerInfo => {
    const { host, port, origin } = wxt.config.dev.server!;
    return { host, port, origin };
  };

  let [runner, builderServer] = await Promise.all([
    createExtensionRunner(),
    wxt.builder.createServer(getServerInfo()),
  ]);

  // Used to track if modules need to be re-initialized
  let wasStopped = false;

  // Server instance must be created first so its reference can be added to the internal config used
  // to pre-render entrypoints
  const server: WxtDevServer = {
    get host() {
      return getServerInfo().host;
    },
    get port() {
      return getServerInfo().port;
    },
    get origin() {
      return getServerInfo().origin;
    },
    get watcher() {
      return builderServer.watcher;
    },
    get ws() {
      return builderServer.ws;
    },
    currentOutput: undefined,
    async start() {
      if (wasStopped) {
        await wxt.reloadConfig();
        runner = await createExtensionRunner();
        builderServer = await wxt.builder.createServer(getServerInfo());
        await initWxtModules();
      }

      await builderServer.listen();
      const hostInfo =
        server.host === 'localhost' ? '' : ` (listening on ${server.host})`;
      wxt.logger.success(`Started dev server @ ${server.origin}${hostInfo}`);
      await wxt.hooks.callHook('server:started', wxt, server);

      // Register content scripts for the first time after the background starts
      // up since they're not listed in the manifest.
      // Add listener before opening the browser to guarantee it is present when
      // the extension sends back the initialization message.
      server.ws.on('wxt:background-initialized', () => {
        if (server.currentOutput == null) return;
        reloadContentScripts(server.currentOutput.steps, server);
      });

      await buildAndOpenBrowser();

      // Listen for file changes and reload different parts of the extension accordingly
      const reloadOnChange = createFileReloader(server);
      server.watcher.on('all', reloadOnChange);
      keyboardShortcuts.start();
      keyboardShortcuts.printHelp({
        canReopenBrowser:
          !wxt.config.runnerConfig.config.disabled && !!runner.canOpen?.(),
      });
    },

    async stop() {
      wasStopped = true;
      keyboardShortcuts.stop();
      await runner.closeBrowser();
      await builderServer.close();
      await wxt.hooks.callHook('server:closed', wxt, server);

      deinitWxtModules();
      server.currentOutput = undefined;
    },
    async restart() {
      await server.stop();
      await server.start();
    },
    transformHtml(url, html, originalUrl) {
      return builderServer.transformHtml(url, html, originalUrl);
    },
    reloadContentScript(payload) {
      server.ws.send('wxt:reload-content-script', payload);
    },
    reloadPage(path) {
      server.ws.send('wxt:reload-page', path);
    },
    reloadExtension() {
      server.ws.send('wxt:reload-extension');
    },
    async restartBrowser() {
      await runner.closeBrowser();
      keyboardShortcuts.stop();
      await wxt.reloadConfig();
      runner = await createExtensionRunner();
      await runner.openBrowser();
      keyboardShortcuts.start();
    },
  };
  const keyboardShortcuts = createKeyboardShortcuts(server);

  const buildAndOpenBrowser = async () => {
    try {
      // Build after starting the dev server so it can be used to transform HTML files
      server.currentOutput = await internalBuild();
    } catch (err) {
      if (!isBabelSyntaxError(err)) {
        throw err;
      }
      logBabelSyntaxError(err);
      wxt.logger.info('Waiting for syntax error to be fixed...');
      await new Promise<void>((resolve) => {
        const watcher = chokidar.watch(err.id, { ignoreInitial: true });
        watcher.on('all', () => {
          watcher.close();
          wxt.logger.info('Syntax error resolved, rebuilding...');
          resolve();
        });
      });
      return buildAndOpenBrowser();
    }

    // Add file watchers for files not loaded by the dev server. See
    // https://github.com/wxt-dev/wxt/issues/428#issuecomment-1944731870
    try {
      server.watcher.add(getExternalOutputDependencies(server));
    } catch (err) {
      wxt.config.logger.warn('Failed to register additional file paths:', err);
    }

    // Open browser after everything is ready to go.
    await runner.openBrowser();
  };

  builderServer.on?.('close', () => keyboardShortcuts.stop());

  return server;
}

/**
 * Returns a function responsible for reloading different parts of the extension when a file
 * changes.
 */
function createFileReloader(server: WxtDevServer) {
  const fileChangedMutex = new Mutex();
  const changeQueue: Array<[string, string]> = [];

  const cb = async (event: string, path: string) => {
    changeQueue.push([event, path]);

    const reloading = fileChangedMutex.runExclusive(async () => {
      if (server.currentOutput == null) return;

      const fileChanges = changeQueue
        .splice(0, changeQueue.length)
        .map(([_, file]) => file);
      if (fileChanges.length === 0) return;

      await wxt.reloadConfig();

      const changes = detectDevChanges(fileChanges, server.currentOutput);
      if (changes.type === 'no-change') return;

      if (changes.type === 'full-restart') {
        wxt.logger.info('Config changed, restarting server...');
        server.restart();
        return;
      }

      if (changes.type === 'browser-restart') {
        wxt.logger.info('Runner config changed, restarting browser...');
        server.restartBrowser();
        return;
      }

      // Log the entrypoints that were effected
      wxt.logger.info(
        `Changed: ${Array.from(new Set(fileChanges))
          .map((file) => pc.dim(relative(wxt.config.root, file)))
          .join(', ')}`,
      );

      // Rebuild entrypoints on change
      const allEntrypoints = await findEntrypoints();
      try {
        const { output: newOutput } = await rebuild(
          allEntrypoints,
          // TODO: this excludes new entrypoints, so they're not built until the dev command is restarted
          changes.rebuildGroups,
          changes.cachedOutput,
        );
        server.currentOutput = newOutput;

        // Perform reloads
        switch (changes.type) {
          case 'extension-reload':
            server.reloadExtension();
            wxt.logger.success(`Reloaded extension`);
            break;
          case 'html-reload':
            const { reloadedNames } = reloadHtmlPages(
              changes.rebuildGroups,
              server,
            );
            wxt.logger.success(`Reloaded: ${getFilenameList(reloadedNames)}`);
            break;
          case 'content-script-reload':
            reloadContentScripts(changes.changedSteps, server);

            const rebuiltNames = changes.rebuildGroups
              .flat()
              .map((entry) => entry.name);
            wxt.logger.success(`Reloaded: ${getFilenameList(rebuiltNames)}`);
            break;
        }
      } catch {
        // Catch build errors instead of crashing. Don't log error either, builder should have already logged it
      }
    });

    await reloading.catch((error) => {
      if (!isBabelSyntaxError(error)) {
        throw error;
      }
      // Log syntax errors without crashing the server.
      logBabelSyntaxError(error);
    });
  };

  return debounce(cb, wxt.config.dev.server!.watchDebounce, {
    leading: true,
    trailing: false,
  });
}

/**
 * From the server, tell the client to reload content scripts from the provided build step outputs.
 */
function reloadContentScripts(steps: BuildStepOutput[], server: WxtDevServer) {
  if (wxt.config.manifestVersion === 3) {
    steps.forEach((step) => {
      if (server.currentOutput == null) return;

      const entry = step.entrypoints;
      if (Array.isArray(entry) || entry.type !== 'content-script') return;

      const js = getContentScriptJs(wxt.config, entry);
      const cssMap = getContentScriptsCssMap(server.currentOutput, [entry]);
      const css = getContentScriptCssFiles([entry], cssMap);

      server.reloadContentScript({
        registration: entry.options.registration,
        contentScript: mapWxtOptionsToRegisteredContentScript(
          entry.options,
          js,
          css,
        ),
      });
    });
  } else {
    server.reloadExtension();
  }
}

function reloadHtmlPages(
  groups: EntrypointGroup[],
  server: WxtDevServer,
): { reloadedNames: string[] } {
  // groups might contain other files like background/content scripts, and we only care about the HTMl pages
  const htmlEntries = groups.flat().filter(isHtmlEntrypoint);

  htmlEntries.forEach((entry) => {
    const path = getEntrypointBundlePath(entry, wxt.config.outDir, '.html');
    server.reloadPage(path);
  });

  return {
    reloadedNames: htmlEntries.map((entry) => entry.name),
  };
}

function getFilenameList(names: string[]): string {
  return names
    .map((name) => {
      return pc.cyan(name);
    })
    .join(pc.dim(', '));
}

/**
 * Based on the current build output, return a list of files that are:
 * 1. Not in node_modules
 * 2. Not inside project root
 */
function getExternalOutputDependencies(server: WxtDevServer) {
  return (
    server.currentOutput?.steps
      .flatMap((step, i) => {
        if (Array.isArray(step.entrypoints) && i === 0) {
          // Dev server is already watching all HTML/esm files
          return [];
        }

        return step.chunks.flatMap((chunk) => {
          if (chunk.type === 'asset') return [];
          return chunk.moduleIds;
        });
      })
      .filter(
        (file) => !file.includes('node_modules') && !file.startsWith('\x00'),
      )
      .map(unnormalizePath)
      .filter((file) => !file.startsWith(wxt.config.root)) ?? []
  );
}
