/**
 * Copyright (c) Microsoft Corporation.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import fs from 'fs';
import path from 'path';

import { iso, utils, getPlaywrightVersion } from 'playwright-core/lib/coreBundle';
import { colors, debug } from 'playwright-core/lib/utilsBundle';
import { cc, transform } from 'playwright/lib/common';
import { removeDirAndLogToConsole } from 'playwright/lib/util';

import { source as injectedSource } from './generated/indexSource';
import { createConfig, frameworkConfig, hasJSComponents, populateComponentsFromTests, resolveDirs, transformIndexFile } from './viteUtils';

import type http from 'http';
import type { AddressInfo } from 'net';
import type { FullConfig, Suite } from 'playwright/types/testReporter';
import type { PluginContext } from 'rollup';
import type { Plugin, ResolveFn, ResolvedConfig } from 'vite';
import type { TestRunnerPlugin } from '../../playwright/src/plugins';
import type { ImportInfo } from './tsxTransform';
import type { ComponentRegistry } from './viteUtils';


const log = debug('pw:vite');

let previewHttpServer: http.Server | undefined;
const playwrightVersion = getPlaywrightVersion();

export function createPlugin(): TestRunnerPlugin {
  let configDir: string;
  let config: FullConfig;
  return {
    name: 'playwright-vite-plugin',

    setup: async (configObject: FullConfig, configDirectory: string) => {
      config = configObject;
      configDir = configDirectory;
    },

    begin: async (suite: Suite) => {
      const result = await buildBundle(config, configDir);
      if (!result)
        return;

      const { viteConfig } = result;
      const { preview } = await import('vite');
      const previewServer = await preview(viteConfig);
      previewHttpServer = previewServer.httpServer as http.Server;
      utils.decorateServer(previewHttpServer);
      const isAddressInfo = (x: any): x is AddressInfo => x?.address;
      const address = previewServer.httpServer.address();
      if (isAddressInfo(address)) {
        const protocol = viteConfig.preview.https ? 'https:' : 'http:';
        process.env.PLAYWRIGHT_TEST_BASE_URL = `${protocol}//${viteConfig.preview.host}:${address.port}`;
      }
    },

    end: async () => {
      if (previewHttpServer)
        await new Promise<void>(f => previewHttpServer!.close(() => f()));
    },

    populateDependencies: async () => {
      await buildBundle(config, configDir);
    },

    clearCache: async () => {
      const configDir = config.configFile ? path.dirname(config.configFile) : config.rootDir;
      const dirs = await resolveDirs(configDir, config);
      if (dirs)
        await removeDirAndLogToConsole(dirs.outDir);
    },
  };
}

type BuildInfo = {
  version: string,
  viteVersion: string,
  registerSourceHash: string,
  sources: {
    [key: string]: {
      timestamp: number;
    }
  };
  components: ImportInfo[];
  deps: {
    [key: string]: string[];
  }
};

export async function buildBundle(config: FullConfig, configDir: string): Promise<{ buildInfo: BuildInfo, viteConfig: Record<string, any> } | null> {
  const { registerSourceFile, frameworkPluginFactory } = frameworkConfig(config);
  const dirs = await resolveDirs(configDir, config);
  if (!dirs) {
    // eslint-disable-next-line no-console
    console.log(colors.red(`Component testing template file playwright/index.html is missing and there is no existing Vite server. Component tests will fail.\n`));
    return null;
  }

  const buildInfoFile = path.join(dirs.outDir, 'metainfo.json');

  let buildExists = false;
  let buildInfo: BuildInfo;

  const registerSource = injectedSource + '\n' + await fs.promises.readFile(registerSourceFile, 'utf-8');
  const registerSourceHash = utils.calculateSha1(registerSource);

  const { version: viteVersion, build, mergeConfig } = await import('vite');

  try {
    buildInfo = JSON.parse(await fs.promises.readFile(buildInfoFile, 'utf-8')) as BuildInfo;
    iso.assert(buildInfo.version === playwrightVersion);
    iso.assert(buildInfo.viteVersion === viteVersion);
    iso.assert(buildInfo.registerSourceHash === registerSourceHash);
    buildExists = true;
  } catch (e) {
    buildInfo = {
      version: playwrightVersion,
      viteVersion,
      registerSourceHash,
      components: [],
      sources: {},
      deps: {},
    };
  }
  log('build exists:', buildExists);

  const componentRegistry: ComponentRegistry = new Map();
  const componentsByImportingFile = new Map<string, string[]>();
  // 1. Populate component registry based on tests' component imports.
  await populateComponentsFromTests(componentRegistry, componentsByImportingFile);

  // 2. Check if the set of required components has changed.
  const hasNewComponents = await checkNewComponents(buildInfo, componentRegistry);
  log('has new components:', hasNewComponents);

  // 3. Check component sources.
  const sourcesDirty = !buildExists || hasNewComponents || await checkSources(buildInfo);
  log('sourcesDirty:', sourcesDirty);

  // 4. Update component info.
  buildInfo.components = [...componentRegistry.values()];

  const jsxInJS = hasJSComponents(buildInfo.components);
  const viteConfig = await createConfig(dirs, config, frameworkPluginFactory, jsxInJS);

  if (sourcesDirty) {
    // Only add our own plugin when we actually build / transform.
    log('build');
    const depsCollector = new Map<string, string[]>();
    const buildConfig = mergeConfig(viteConfig, {
      plugins: [vitePlugin(registerSource, dirs.templateDir, buildInfo, componentRegistry, depsCollector)]
    });
    await build(buildConfig);
    buildInfo.deps = Object.fromEntries(depsCollector.entries());
  }

  {
    // Update dependencies based on the vite build.
    for (const [importingFile, components] of componentsByImportingFile) {
      const deps = new Set<string>();
      for (const component of components) {
        for (const d of buildInfo.deps[component])
          deps.add(d);
      }
      cc.setExternalDependencies(importingFile, [...deps]);
    }
  }

  if (hasNewComponents || sourcesDirty) {
    log('write manifest');
    await fs.promises.writeFile(buildInfoFile, JSON.stringify(buildInfo, undefined, 2));
  }
  return { buildInfo, viteConfig };
}

async function checkSources(buildInfo: BuildInfo): Promise<boolean> {
  for (const [source, sourceInfo] of Object.entries(buildInfo.sources)) {
    try {
      const timestamp = (await fs.promises.stat(source)).mtimeMs;
      if (sourceInfo.timestamp !== timestamp) {
        log('source has changed:', source);
        return true;
      }
    } catch (e) {
      log('check source failed:', e);
      return true;
    }
  }
  return false;
}

async function checkNewComponents(buildInfo: BuildInfo, componentRegistry: ComponentRegistry): Promise<boolean> {
  const newComponents = [...componentRegistry.keys()];
  const oldComponents = new Map(buildInfo.components.map(c => [c.id, c]));

  let hasNewComponents = false;
  for (const c of newComponents) {
    if (!oldComponents.has(c)) {
      hasNewComponents = true;
      break;
    }
  }
  for (const c of oldComponents.values()) {
    try {
      if ((await fs.promises.stat(c.filename)))
        componentRegistry.set(c.id, c);
    } catch (e) {
      if (e.code !== 'ENOENT')
        throw e;
      log('non existent file, skipping component registry:', c.filename);
    }
  }

  return hasNewComponents;
}

function vitePlugin(registerSource: string, templateDir: string, buildInfo: BuildInfo, importInfos: Map<string, ImportInfo>, depsCollector: Map<string, string[]>): Plugin {
  buildInfo.sources = {};
  let moduleResolver: ResolveFn;
  return {
    name: 'playwright:component-index',

    configResolved(config: ResolvedConfig) {
      moduleResolver = config.createResolver();
    },

    async transform(this: PluginContext, content, id) {
      const queryIndex = id.indexOf('?');
      const file = queryIndex !== -1 ? id.substring(0, queryIndex) : id;
      if (!buildInfo.sources[file]) {
        try {
          const timestamp = (await fs.promises.stat(file)).mtimeMs;
          buildInfo.sources[file] = { timestamp };
        } catch {
          // Silent if can't read the file.
        }
      }
      return transformIndexFile(id, content, templateDir, registerSource, importInfos);
    },

    async writeBundle(this: PluginContext) {
      for (const importInfo of importInfos.values()) {
        const importPath = transform.resolveHook(importInfo.filename, importInfo.importSource);
        if (!importPath)
          continue;
        const deps = new Set<string>();
        const id = await moduleResolver(importPath);
        if (!id)
          continue;
        collectViteModuleDependencies(this, id, deps);
        depsCollector.set(importPath, [...deps]);
      }
    },
  };
}

function collectViteModuleDependencies(context: PluginContext, id: string, deps: Set<string>) {
  // Example: "src/Component.tsx?raw" or "src/Component.tsx?import" should resolve to the file path.
  const cleanedId = id.split(/[?#]/)[0];
  const normalizedId = path.normalize(cleanedId);
  if (!path.isAbsolute(normalizedId))
    return;
  if (deps.has(normalizedId))
    return;
  deps.add(normalizedId);
  const module = context.getModuleInfo(id);
  for (const importedId of module?.importedIds || [])
    collectViteModuleDependencies(context, importedId, deps);
  for (const importedId of module?.dynamicallyImportedIds || [])
    collectViteModuleDependencies(context, importedId, deps);
}
