import {
  Entrypoint,
  PublicPathEntry,
  WxtDirEntry,
  WxtDirFileEntry,
} from '../types';
import { mkdir, readFile } from 'node:fs/promises';
import { dirname, relative, resolve } from 'node:path';
import { getEntrypointBundlePath, isHtmlEntrypoint } from './utils/entrypoints';
import { getEntrypointGlobals, getGlobals } from './utils/globals';
import { normalizePath } from './utils';
import path from 'node:path';
import { Message, parseI18nMessages } from './utils/i18n';
import { pathExists, writeFileIfDifferent, getPublicFiles } from './utils/fs';
import { wxt } from './wxt';

/**
 * Generate and write all the files inside the `InternalConfig.typesDir`
 * directory.
 */
export async function generateWxtDir(entrypoints: Entrypoint[]): Promise<void> {
  await mkdir(wxt.config.typesDir, { recursive: true });

  const entries: WxtDirEntry[] = [
    // Hard-coded entries
    { module: 'wxt/vite-builder-env' },
  ];

  // Add references to modules installed from NPM to the TS project so their
  // type augmentation can update InlineConfig correctly. Local modules defined
  // in <root>/modules are already a part of the project, so we don't need to
  // add them.
  wxt.config.userModules.forEach((module) => {
    if (module.type === 'node_module') entries.push({ module: module.id });
  });

  // browser.runtime.getURL
  entries.push(await getPathsDeclarationEntry(entrypoints));

  // browser.i18n.getMessage
  entries.push(await getI18nDeclarationEntry());

  // import.meta.env.*
  entries.push(await getGlobalsDeclarationEntry());

  // tsconfig.json
  entries.push(await getTsConfigEntry());

  // Let modules add more entries
  await wxt.hooks.callHook('prepare:types', wxt, entries);

  // Add main declaration file, not editable
  entries.push(getMainDeclarationEntry(entries));

  // Write all the files
  const absoluteFileEntries = (
    entries.filter((entry) => 'path' in entry) as WxtDirFileEntry[]
  ).map<WxtDirFileEntry>((entry) => ({
    ...entry,
    path: resolve(wxt.config.wxtDir, entry.path),
  }));

  await Promise.all(
    absoluteFileEntries.map(async (file) => {
      await mkdir(dirname(file.path), { recursive: true });
      await writeFileIfDifferent(file.path, file.text);
    }),
  );
}

async function getPathsDeclarationEntry(
  entrypoints: Entrypoint[],
): Promise<WxtDirFileEntry> {
  const paths: PublicPathEntry[] = entrypoints
    .map((entry) =>
      getEntrypointBundlePath(
        entry,
        wxt.config.outDir,
        getEntrypointPublicExt(entry),
      ),
    )
    .concat(await getPublicFiles());

  await wxt.hooks.callHook('prepare:publicPaths', wxt, paths);

  const unions = [
    `    | ""`,
    `    | "/"`,
    ...paths
      .map(normalizePublicPathEntry)
      .sort(
        (a, b) => a.path.localeCompare(b.path) || a.type.localeCompare(b.type),
      )
      .map(renderPublicPathUnionMember),
  ].join('\n');

  const template = `// Generated by wxt
import "wxt/browser";

declare module "wxt/browser" {
  export type PublicPath =
{{ union }}
  type HtmlPublicPath = Extract<PublicPath, \`\${string}.html\`>
  export interface WxtRuntime {
    getURL(path: PublicPath): string;
    getURL(path: \`\${HtmlPublicPath}\${string}\`): string;
  }
}
`;

  return {
    path: 'types/paths.d.ts',
    text: template.replace('{{ union }}', unions || '    | never'),
    tsReference: true,
  };
}

function normalizePublicPathEntry(entry: PublicPathEntry): {
  type: 'string' | 'templateLiteral';
  path: string;
} {
  if (typeof entry === 'string') {
    return {
      type: 'string',
      path: normalizePath(entry),
    };
  }

  return {
    type: entry.type,
    path: normalizePath(entry.path),
  };
}

function renderPublicPathUnionMember(entry: {
  type: 'string' | 'templateLiteral';
  path: string;
}): string {
  const path = entry.path.startsWith('/') ? entry.path : `/${entry.path}`;

  switch (entry.type) {
    case 'templateLiteral':
      return `    | \`${path}\``;
    case 'string':
      return `    | "${path}"`;
  }
}

function getEntrypointPublicExt(entry: Entrypoint): '.html' | '.js' | '.css' {
  if (isHtmlEntrypoint(entry)) return '.html';

  switch (entry.type) {
    case 'content-script-style':
    case 'unlisted-style':
      return '.css';
    default:
      return '.js';
  }
}

async function getI18nDeclarationEntry(): Promise<WxtDirFileEntry> {
  const defaultLocale = wxt.config.manifest.default_locale;
  const template = `// Generated by wxt
import "wxt/browser";

declare module "wxt/browser" {
  /**
   * See https://developer.chrome.com/docs/extensions/reference/i18n/#method-getMessage
   */
  interface GetMessageOptions {
    /**
     * See https://developer.chrome.com/docs/extensions/reference/i18n/#method-getMessage
     */
    escapeLt?: boolean
  }

  export interface WxtI18n extends I18n.Static {
{{ overrides }}
  }
}
`;

  const defaultLocalePath = path.resolve(
    wxt.config.publicDir,
    '_locales',
    defaultLocale ?? '',
    'messages.json',
  );
  let messages: Message[];
  if (await pathExists(defaultLocalePath)) {
    const content = JSON.parse(await readFile(defaultLocalePath, 'utf-8'));
    messages = parseI18nMessages(content);
  } else {
    messages = parseI18nMessages({});
  }

  const renderGetMessageOverload = (
    keyType: string,
    description?: string,
    translation?: string,
  ) => {
    const commentLines: string[] = [];
    if (description) commentLines.push(...description.split('\n'));
    if (translation) {
      if (commentLines.length > 0) commentLines.push('');
      commentLines.push(`"${translation}"`);
    }
    const comment =
      commentLines.length > 0
        ? `/**\n${commentLines.map((line) => `     * ${line}`.trimEnd()).join('\n')}\n     */\n    `
        : '';
    return `    ${comment}getMessage(
      messageName: ${keyType},
      substitutions?: string | string[],
      options?: GetMessageOptions,
    ): string;`;
  };

  const overrides = [
    // Generate individual overloads for each message so JSDoc contains
    // description and base translation.
    ...messages.map((message) =>
      renderGetMessageOverload(
        `"${message.name}"`,
        message.description,
        message.message,
      ),
    ),
    // Include a final union-based override so TS accepts valid string
    // templates or concatenations.
    // ie: browser.i18n.getMessage(`some_enum_${enumValue}`)
    renderGetMessageOverload(
      messages.map((message) => `"${message.name}"`).join(' | '),
    ),
  ];

  return {
    path: 'types/i18n.d.ts',
    text: template.replace('{{ overrides }}', overrides.join('\n')),
    tsReference: true,
  };
}

async function getGlobalsDeclarationEntry(): Promise<WxtDirFileEntry> {
  const globals = [...getGlobals(wxt.config), ...getEntrypointGlobals('')];
  return {
    path: 'types/globals.d.ts',
    text: [
      '// Generated by wxt',
      'interface ImportMetaEnv {',
      ...globals.map((global) => `  readonly ${global.name}: ${global.type};`),
      '}',
      'interface ImportMeta {',
      '  readonly env: ImportMetaEnv',
      '}',
      '',
    ].join('\n'),
    tsReference: true,
  };
}

function getMainDeclarationEntry(references: WxtDirEntry[]): WxtDirFileEntry {
  const lines = ['// Generated by wxt'];
  references.forEach((ref) => {
    if ('module' in ref) {
      return lines.push(`/// <reference types="${ref.module}" />`);
    } else if (ref.tsReference) {
      const absolutePath = resolve(wxt.config.wxtDir, ref.path);
      const relativePath = relative(wxt.config.wxtDir, absolutePath);
      lines.push(`/// <reference path="./${normalizePath(relativePath)}" />`);
    }
  });
  return {
    path: 'wxt.d.ts',
    text: lines.join('\n') + '\n',
  };
}

async function getTsConfigEntry(): Promise<WxtDirFileEntry> {
  const dir = wxt.config.wxtDir;
  const getTsconfigPath = (path: string) => {
    const res = normalizePath(relative(dir, path));
    if (res.startsWith('.') || res.startsWith('/')) return res;
    return './' + res;
  };

  const paths = Object.fromEntries(
    Object.entries(wxt.config.alias)
      .map(([alias, absolutePath]) => [alias, getTsconfigPath(absolutePath)])
      .flatMap(([alias, path]) => [
        // Add alias for files
        [alias, [path]],
        // Add alias for all files in a directory
        [alias + '/*', [path + '/*']],
      ]),
  );

  const tsconfig = {
    compilerOptions: {
      // Environment setup & latest features
      lib: ['ESNext', 'DOM', 'DOM.Iterable'],
      target: 'ESNext',
      module: 'Preserve',
      moduleDetection: 'force',
      // Bundler mode
      moduleResolution: 'Bundler',
      allowImportingTsExtensions: true,
      verbatimModuleSyntax: true,
      noEmit: true,
      // Best practices
      strict: true,
      skipLibCheck: true,
      noFallthroughCasesInSwitch: true,
      noUncheckedIndexedAccess: true,
      noImplicitOverride: true,
      // Project settings
      paths,
    },
    include: [`${getTsconfigPath(wxt.config.root)}/**/*`, './wxt.d.ts'],
    exclude: [
      getTsconfigPath(wxt.config.root) + '/**/node_modules',
      getTsconfigPath(wxt.config.outBaseDir),
    ],
  };

  await wxt.hooks.callHook('prepare:tsconfig', wxt, { tsconfig });

  return {
    path: 'tsconfig.json',
    text: JSON.stringify(tsconfig, null, 2),
  };
}
