import merge from 'lodash.merge';
import { Result, x as spawn } from 'tinyexec';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { createServer as createNetServer } from 'node:net';
import { dirname, relative, resolve } from 'path';
import { glob } from 'tinyglobby';
import {
  InlineConfig,
  UserConfig,
  WxtCommand,
  build,
  createServer,
  prepare,
  zip,
} from '../src';
import { normalizePath } from '../src/core/utils';
import { pathExists, readJson } from '../src/core/utils/fs';
import { registerWxt } from '../src/core/wxt';
import * as vite from 'vite';

// Run "bun wxt" to use the "wxt" dev script, not the "wxt" binary from the
// wxt package. This uses the TS files instead of the compiled JS package
// files.
export const WXT_PACKAGE_DIR = resolve(__dirname, '..');

export const E2E_DIR = resolve(WXT_PACKAGE_DIR, 'e2e');

export class TestProject {
  /**
   * Create the simplest WXT project possible: one blank popup entrypoint, no
   * custom config.
   */
  static simple(): TestProject {
    const project = new TestProject();

    project.addFile('entrypoints/popup.html', '<html></html>');

    return project;
  }

  files: Array<[string, string]> = [];
  config: UserConfig | undefined;
  readonly root: string;
  readonly hasCustomDependencies: boolean;

  constructor(packageJson: any = {}) {
    // We can't put each test's project inside e2e/dist directly, otherwise the wxt.config.ts
    // file is cached and cannot be different between each test. Instead, we add a random ID to the
    // end to make each test's path unique.
    const id = Math.random().toString(32).substring(3);
    this.root = resolve(E2E_DIR, 'dist', id);

    this.hasCustomDependencies =
      Object.keys({
        ...packageJson.dependencies,
        ...packageJson.devDependencies,
      }).length > 0;

    this.files.push([
      'package.json',
      JSON.stringify(
        merge(
          {
            name: 'E2E Extension',
            description: 'Example description',
            version: '0.0.0',
          },
          packageJson,
        ),
        null,
        2,
      ),
    ]);
  }

  /** Add a `wxt.config.ts` to the project with specific contents. */
  setConfigFileConfig(config: UserConfig = {}) {
    this.config = config;
    this.files.push([
      'wxt.config.ts',
      `import { defineConfig } from 'wxt'\n\nexport default defineConfig(${JSON.stringify(
        config,
        null,
        2,
      )})`,
    ]);
  }

  /**
   * Adds the file to the project. Stored in memory until `.build` is called.
   *
   * @param filename Filename relative to the project's root.
   * @param content File content.
   * @returns The absolute path to the file that was added.
   */
  addFile(filename: string, content?: string) {
    this.files.push([filename, content ?? '']);
    if (filename === 'wxt.config.ts') this.config = {};
    return this.resolvePath(filename);
  }

  /**
   * Register the global `wxt` object for this project. After calling, you can
   * import `wxt` like normal and inspect it.
   */
  async registerWxt(command: WxtCommand, config: InlineConfig = {}) {
    await this.writeProjectToDisk();
    await registerWxt(command, this.generateWxtConfig(config));
  }

  async prepare(config: InlineConfig = {}) {
    await this.writeProjectToDisk();
    await prepare(this.generateWxtConfig(config));
  }

  async build(config: InlineConfig = {}) {
    await this.writeProjectToDisk();
    return await build(this.generateWxtConfig(config));
  }

  async zip(config: InlineConfig = {}) {
    await this.writeProjectToDisk();
    await zip(this.generateWxtConfig(config));
  }

  async startServer(config: InlineConfig = {}) {
    await this.writeProjectToDisk();
    const server = await createServer(this.generateWxtConfig(config));
    await server.start();
    return server;
  }

  /** Call `path.resolve` relative to the project's root directory. */
  resolvePath(...path: string[]): string {
    return resolve(this.root, ...path);
  }

  private generateWxtConfig(inline: InlineConfig): InlineConfig {
    return {
      ...inline,
      vite: async (...args) => {
        const inlineVite = (await inline.vite?.(...args)) ?? {};
        return vite.mergeConfig<vite.UserConfig, vite.UserConfig>(
          {
            build: {
              rolldownOptions: {
                experimental: {
                  // Disable region comments - sometimes these included the e2e
                  // project's root, which changes every test, changing the chunk
                  // hashes and output strings every test run.
                  attachDebugInfo: 'none',
                },
              },
            },
          },
          inlineVite,
        );
      },
      root: this.root,
    };
  }

  private async writeProjectToDisk() {
    if (this.config == null) this.setConfigFileConfig();

    for (const file of this.files) {
      const [name, content] = file;
      const filePath = this.resolvePath(name);
      const fileDir = dirname(filePath);
      await mkdir(fileDir, { recursive: true });
      await writeFile(filePath, content ?? '', 'utf-8');
    }

    // Only install dependencies if the project has custom ones - otherwise the
    // project will reuse the ones in `packages/wxt/node_modules`!
    if (this.hasCustomDependencies) {
      await spawn('bun', ['install', '--ignore-scripts'], {
        throwOnError: true,
        nodeOptions: {
          cwd: this.root,
        },
      });
    }

    await mkdir(resolve(this.root, 'public'), { recursive: true }).catch(
      () => {},
    );
  }

  /**
   * Read all the files from the test project's `.output` directory and combine
   * them into a string that can be used in a snapshot.
   *
   * Optionally, provide a list of filenames whose content is not printed
   * (because it's inconsistent or not relevant to a test).
   */
  serializeOutput(ignoreContentsOfFilenames?: string[]): Promise<string> {
    return this.serializeDir('.output', ignoreContentsOfFilenames);
  }

  /**
   * Deeply print the filename and contents of all files in a directory.
   *
   * Optionally, provide a list of filenames whose content is not printed
   * (because it's inconsistent or not relevant to a test).
   */
  private async serializeDir(
    dir: string,
    ignoreContentsOfFilenames?: string[],
  ): Promise<string> {
    const outputFiles = await glob('**/*', {
      cwd: this.resolvePath(dir),
      ignore: ['**/node_modules', '**/.output'],
      expandDirectories: false,
    });
    outputFiles.sort();
    const fileContents = [];
    for (const file of outputFiles) {
      const path = this.resolvePath(dir, file);
      const isContentIgnored = !!ignoreContentsOfFilenames?.find(
        (ignoredFile) => normalizePath(path).endsWith(ignoredFile),
      );
      fileContents.push(await this.serializeFile(path, isContentIgnored));
    }
    return fileContents.join(`\n${''.padEnd(80, '=')}\n`);
  }

  /**
   * @param path An absolute path to a file or a path relative to the root.
   * @param ignoreContents An optional boolean that, when true, causes this
   *   function to not print the file contents.
   */
  async serializeFile(path: string, ignoreContents?: boolean): Promise<string> {
    const absolutePath = this.resolvePath(path);
    return [
      normalizePath(relative(this.root, absolutePath)),
      ignoreContents
        ? '<contents-ignored>'
        : await readFile(absolutePath, 'utf-8'),
    ].join(`\n${''.padEnd(40, '-')}\n`);
  }

  pathExists(...path: string[]): Promise<boolean> {
    return pathExists(this.resolvePath(...path));
  }

  getOutputManifest(
    path: string = '.output/chrome-mv3/manifest.json',
  ): Promise<any> {
    return readJson(this.resolvePath(path));
  }

  /** Run a command using the project's package manager. */
  async run(...args: string[]): Promise<Result> {
    return await spawn('bun', args, {
      throwOnError: true,
      nodeOptions: {
        cwd: this.root,
      },
    });
  }
}

/** Starts a TCP server on the given port and returns a cleanup function. */
export function occupyPort(port: number): Promise<() => Promise<void>> {
  return new Promise((resolve, reject) => {
    const srv = createNetServer();
    srv.listen(port, 'localhost', () => {
      resolve(() => new Promise<void>((res) => srv.close(() => res())));
    });
    srv.on('error', reject);
  });
}
