import { createHash } from 'node:crypto';
import { dirname, isAbsolute, relative, resolve } from 'pathe';
import * as Effect from 'effect/Effect';
import type { Route, PluginOptions, RouteManifestItem } from './types.js';
import { combineURLs, createRouteId } from './plugin-utils.js';
import { SERVER_EXPORTS, CLIENT_EXPORTS } from './constants.js';
import {
  buildManifestChunkValidity,
  createEmptyRouteChunkByExportName,
  detectRouteChunksIfEnabled,
  getRouteChunkEntryName,
  routeChunkExportNames,
  validateRouteChunks,
  type RouteChunkCache,
  type RouteChunkConfig,
} from './route-chunks.js';
import { getRouteModuleAnalysis } from './export-utils.js';
import { getCappedPluginConcurrency } from './concurrency.js';
import { runPluginEffect, tryPluginPromise } from './effect-runtime.js';

const ROUTE_ANALYSIS_CONCURRENCY = getCappedPluginConcurrency();

/**
 * Structural equivalent of `RouteConfigEntry` from `@react-router/dev/routes`,
 * inlined so the public declaration files do not reference a devDependency
 * (which would fail to resolve for consumers with `skipLibCheck: false`).
 */
export interface RouteConfigEntry {
  id?: string;
  path?: string;
  index?: boolean;
  caseSensitive?: boolean;
  file: string;
  children?: RouteConfigEntry[];
}

export function configRoutesToRouteManifest(
  appDirectory: string,
  routes: RouteConfigEntry[],
  rootId = 'root'
): Record<string, Route> {
  return Object.fromEntries(
    configRoutesToRouteManifestEntries(appDirectory, routes, rootId)
  );
}

export function configRoutesToRouteManifestEntries(
  appDirectory: string,
  routes: RouteConfigEntry[],
  rootId = 'root'
): Array<[string, Route]> {
  const routeManifestEntries: Array<[string, Route]> = [];
  const routeIds = new Set<string>();

  function walk(route: RouteConfigEntry, parentId: string) {
    const id = route.id || createRouteId(route.file);
    const manifestItem = {
      id,
      parentId,
      file: isAbsolute(route.file)
        ? relative(appDirectory, route.file)
        : route.file,
      path: route.path,
      index: route.index,
      caseSensitive: route.caseSensitive,
    };

    if (routeIds.has(id)) {
      throw new Error(
        `Unable to define routes with duplicate route id: "${id}"`
      );
    }
    routeIds.add(id);
    routeManifestEntries.push([id, manifestItem]);

    if (route.children) {
      for (const child of route.children) {
        walk(child, id);
      }
    }
  }

  for (const route of routes) {
    walk(route, rootId);
  }

  return routeManifestEntries;
}

type RouteChunkManifestOptions = {
  splitRouteModules?: boolean | 'enforce';
  rootRouteFile?: string;
  isBuild?: boolean;
  cache?: RouteChunkCache;
};

export type ReactRouterManifestForDev = {
  version: string;
  url: string;
  hmr?: {
    runtime: string;
  };
  entry: {
    module: string;
    imports: string[];
    css: string[];
  };
  sri?: Record<string, string> | true;
  routes: Record<string, RouteManifestItem>;
};

export type ReactRouterManifestStats = {
  assetsByChunkName?: Record<string, string[]>;
  entrypointFilesByName?: Record<string, string[]>;
};

type ReactRouterManifestStatsChunk = {
  files?: Iterable<string>;
};

type ReactRouterManifestStatsEntrypoint = {
  getFiles?: () => Iterable<string>;
};

type ReactRouterManifestStatsLookup<T> = Iterable<[string, T]> & {
  get?: (name: string) => T | undefined;
};

type ReactRouterManifestStatsCompilation = {
  namedChunks: ReactRouterManifestStatsLookup<ReactRouterManifestStatsChunk>;
  entrypoints?: ReactRouterManifestStatsLookup<ReactRouterManifestStatsEntrypoint>;
};

const orderChunkFiles = (chunkName: string, files: string[]): string[] => {
  const ownChunkAsset = `${chunkName}.js`;
  const ownFileIndex = files.findIndex(file => file.endsWith(ownChunkAsset));
  if (ownFileIndex <= 0) {
    return files;
  }

  return [
    files[ownFileIndex],
    ...files.slice(0, ownFileIndex),
    ...files.slice(ownFileIndex + 1),
  ];
};

const collectManifestFilesByName = <T>(
  items: ReactRouterManifestStatsLookup<T>,
  names: ReadonlySet<string> | undefined,
  getFiles: (name: string, item: T) => string[]
): Record<string, string[]> => {
  const filesByName: Record<string, string[]> = {};
  if (!names) {
    for (const [name, item] of items) {
      filesByName[name] = getFiles(name, item);
    }
    return filesByName;
  }

  const missingNames = new Set(names);
  if (typeof items.get === 'function') {
    for (const name of names) {
      const item = items.get(name);
      if (!item) {
        continue;
      }
      filesByName[name] = getFiles(name, item);
      missingNames.delete(name);
    }
  }

  if (missingNames.size === 0) {
    return filesByName;
  }

  for (const [name, item] of items) {
    if (!missingNames.has(name)) {
      continue;
    }
    filesByName[name] = getFiles(name, item);
    missingNames.delete(name);
    if (missingNames.size === 0) {
      break;
    }
  }

  return filesByName;
};

export const createReactRouterManifestStats = (
  compilation: ReactRouterManifestStatsCompilation | undefined,
  chunkNames?: ReadonlySet<string>
): ReactRouterManifestStats | undefined => {
  if (!compilation) {
    return undefined;
  }

  const assetsByChunkName = collectManifestFilesByName(
    compilation.namedChunks,
    chunkNames,
    (chunkName, chunk) =>
      orderChunkFiles(chunkName, Array.from(chunk.files ?? []))
  );
  const entrypointFilesByName = compilation.entrypoints
    ? collectManifestFilesByName(
        compilation.entrypoints,
        chunkNames,
        (_name, entrypoint) => Array.from(entrypoint.getFiles?.() ?? [])
      )
    : {};

  return Object.keys(entrypointFilesByName).length > 0
    ? { assetsByChunkName, entrypointFilesByName }
    : { assetsByChunkName };
};

export type RouteManifestModuleExports = Record<string, readonly string[]>;

export type ReactRouterManifestGenerationResult = {
  manifest: ReactRouterManifestForDev;
  moduleExportsByRouteId: RouteManifestModuleExports;
};

type ChunkAssets = {
  js: string[];
  css: string[];
};

type RouteManifestAnalysis = {
  cssAssets: string[];
  exports: Set<string>;
  routeModuleExports: readonly string[];
  hasRouteChunkByExportName: ReturnType<
    typeof createEmptyRouteChunkByExportName
  > | null;
};

const DEFAULT_MANIFEST_DIR = 'static/js';
const CSS_IMPORT_RE = /\.(?:css|less|sass|scss)(?:\?[^'"`]+)?['"`]/;

const createChunkAssetResolver = (
  clientStats: ReactRouterManifestStats | undefined
): ((chunkName: string) => ChunkAssets) => {
  const chunkAssetsByName = new Map<string, ChunkAssets>();

  return (chunkName: string): ChunkAssets => {
    const cached = chunkAssetsByName.get(chunkName);
    if (cached) {
      return cached;
    }

    const assets = clientStats?.assetsByChunkName?.[chunkName];
    if (!assets) {
      const fallback = `${DEFAULT_MANIFEST_DIR}/${chunkName}.js`;
      const result = { js: [fallback], css: [] };
      chunkAssetsByName.set(chunkName, result);
      return result;
    }

    const cssAssets = new Set<string>();
    const jsAssets: string[] = [];
    for (const asset of assets) {
      if (asset.endsWith('.css')) {
        cssAssets.add(asset);
      } else if (asset.endsWith('.js')) {
        jsAssets.push(asset);
      }
    }
    for (const asset of clientStats?.entrypointFilesByName?.[chunkName] ?? []) {
      if (asset.endsWith('.css')) {
        cssAssets.add(asset);
      }
    }
    if (jsAssets.length === 0) {
      jsAssets.push(`${DEFAULT_MANIFEST_DIR}/${chunkName}.js`);
    }

    const result = { js: jsAssets, css: [...cssAssets] };
    chunkAssetsByName.set(chunkName, result);
    return result;
  };
};

const analyzeRouteForManifestEffect = ({
  discoveredCssAssets,
  isBuild,
  routeChunkCache,
  routeChunkConfig,
  routeEntryName,
  routeFilePath,
}: {
  discoveredCssAssets: string[];
  isBuild: boolean;
  routeChunkCache: RouteChunkCache | undefined;
  routeChunkConfig: RouteChunkConfig | null;
  routeEntryName: string;
  routeFilePath: string;
}): Effect.Effect<RouteManifestAnalysis, Error, never> =>
  tryPluginPromise(async () => {
    const { code, exports: exportNames } =
      await getRouteModuleAnalysis(routeFilePath);
    const cssAssets =
      !isBuild && discoveredCssAssets.length === 0 && CSS_IMPORT_RE.test(code)
        ? [
            `${DEFAULT_MANIFEST_DIR.replace('/js', '/css')}/${routeEntryName}.css`,
          ]
        : discoveredCssAssets;
    const chunkInfo =
      isBuild && routeChunkConfig
        ? await detectRouteChunksIfEnabled(
            routeChunkCache,
            routeChunkConfig,
            routeFilePath,
            code
          )
        : null;

    return {
      cssAssets,
      exports: new Set(exportNames),
      routeModuleExports: exportNames,
      hasRouteChunkByExportName: chunkInfo?.hasRouteChunkByExportName ?? null,
    };
  }).pipe(
    Effect.catchAll(error => {
      if (isBuild) {
        return Effect.fail(error);
      }
      return Effect.sync(() => {
        console.error(`Failed to analyze route file ${routeFilePath}:`, error);
        return {
          cssAssets: discoveredCssAssets,
          exports: new Set<string>(),
          routeModuleExports: [],
          hasRouteChunkByExportName: null,
        };
      });
    })
  );

const getManifestDirFromEntryAsset = (entryModulePath?: string): string => {
  if (!entryModulePath) {
    return DEFAULT_MANIFEST_DIR;
  }
  const dir = dirname(entryModulePath);
  return dir === '.' ? DEFAULT_MANIFEST_DIR : dir;
};

export const getReactRouterManifestPath = ({
  version,
  isBuild,
  entryModulePath,
}: {
  version: string;
  isBuild: boolean;
  entryModulePath?: string;
}): string => {
  if (!isBuild) {
    return 'static/js/virtual/react-router/browser-manifest.js';
  }
  const dir = getManifestDirFromEntryAsset(entryModulePath);
  return `${dir}/manifest-${version}.js`;
};

const getManifestVersion = (
  fingerprintedValues: { entry: unknown; routes: unknown },
  isBuild: boolean
): string => {
  if (!isBuild) {
    return String(Math.random());
  }
  return createHash('md5')
    .update(JSON.stringify(fingerprintedValues))
    .digest('hex')
    .slice(0, 8);
};

const getRouteEntryName = (route: Route): string => {
  const extensionIndex = route.file.lastIndexOf('.');
  return extensionIndex >= 0 ? route.file.slice(0, extensionIndex) : route.file;
};

export const getReactRouterManifestChunkNames = (
  routes: Record<string, Route>,
  splitRouteModules: boolean | 'enforce' = false
): Set<string> => {
  const chunkNames = new Set<string>(['entry.client']);
  for (const route of Object.values(routes)) {
    chunkNames.add(getRouteEntryName(route));
    if (!splitRouteModules || route.id === 'root') {
      continue;
    }
    for (const exportName of routeChunkExportNames) {
      chunkNames.add(getRouteChunkEntryName(route.id, exportName));
    }
  }
  return chunkNames;
};

function generateReactRouterManifestForDevEffect(
  routes: Record<string, Route>,
  _options: PluginOptions,
  clientStats: ReactRouterManifestStats | undefined,
  context: string,
  assetPrefix: string,
  routeChunkOptions?: RouteChunkManifestOptions
): Effect.Effect<ReactRouterManifestGenerationResult, Error, never> {
  return Effect.gen(function* () {
    const result: Record<string, RouteManifestItem> = {};
    const splitRouteModules = routeChunkOptions?.splitRouteModules ?? false;
    const enforceSplitRouteModules = splitRouteModules === 'enforce';
    const isBuild = routeChunkOptions?.isBuild ?? false;
    const routeChunkConfig: RouteChunkConfig | null =
      splitRouteModules && routeChunkOptions?.rootRouteFile
        ? {
            splitRouteModules,
            appDirectory: context,
            rootRouteFile: routeChunkOptions.rootRouteFile,
          }
        : null;

    const getAssetsForChunk = createChunkAssetResolver(clientStats);
    const getModulePathForChunk = (chunkName: string): string | undefined => {
      const { js: jsAssets } = getAssetsForChunk(chunkName);
      return jsAssets[0] ? combineURLs(assetPrefix, jsAssets[0]) : undefined;
    };

    const manifestEntries = yield* Effect.forEach(
      Object.entries(routes),
      ([key, route]) =>
        Effect.gen(function* () {
          const routeEntryName = getRouteEntryName(route);
          const { js: jsAssets, css: discoveredCssAssets } =
            getAssetsForChunk(routeEntryName);
          const routeFilePath = resolve(context, route.file);
          const routeAnalysis = yield* analyzeRouteForManifestEffect({
            discoveredCssAssets,
            isBuild,
            routeChunkCache: routeChunkOptions?.cache,
            routeChunkConfig,
            routeEntryName,
            routeFilePath,
          });

          const hasClientAction = routeAnalysis.exports.has(
            CLIENT_EXPORTS.clientAction
          );
          const hasClientLoader = routeAnalysis.exports.has(
            CLIENT_EXPORTS.clientLoader
          );
          const hasClientMiddleware = routeAnalysis.exports.has(
            CLIENT_EXPORTS.clientMiddleware
          );
          const hasDefaultExport = routeAnalysis.exports.has('default');
          const routeChunkMap = routeAnalysis.hasRouteChunkByExportName;

          if (isBuild && enforceSplitRouteModules && routeChunkConfig) {
            validateRouteChunks({
              config: routeChunkConfig,
              id: routeFilePath,
              valid: buildManifestChunkValidity(
                routeAnalysis.exports,
                routeChunkMap ?? createEmptyRouteChunkByExportName()
              ),
            });
          }

          return [
            key,
            {
              id: route.id,
              parentId: route.parentId,
              path: route.path,
              index: route.index,
              caseSensitive: route.caseSensitive,
              module: combineURLs(assetPrefix, jsAssets[0] || ''),
              clientActionModule: routeChunkMap?.clientAction
                ? getModulePathForChunk(
                    getRouteChunkEntryName(route.id, 'clientAction')
                  )
                : undefined,
              clientLoaderModule: routeChunkMap?.clientLoader
                ? getModulePathForChunk(
                    getRouteChunkEntryName(route.id, 'clientLoader')
                  )
                : undefined,
              clientMiddlewareModule: routeChunkMap?.clientMiddleware
                ? getModulePathForChunk(
                    getRouteChunkEntryName(route.id, 'clientMiddleware')
                  )
                : undefined,
              hydrateFallbackModule: routeChunkMap?.HydrateFallback
                ? getModulePathForChunk(
                    getRouteChunkEntryName(route.id, 'HydrateFallback')
                  )
                : undefined,
              hasAction: routeAnalysis.exports.has(SERVER_EXPORTS.action),
              hasLoader: routeAnalysis.exports.has(SERVER_EXPORTS.loader),
              hasClientAction,
              hasClientLoader,
              hasClientMiddleware,
              hasDefaultExport,
              hasErrorBoundary: routeAnalysis.exports.has(
                CLIENT_EXPORTS.ErrorBoundary
              ),
              imports: jsAssets.map(asset => combineURLs(assetPrefix, asset)),
              css: routeAnalysis.cssAssets.map(asset =>
                combineURLs(assetPrefix, asset)
              ),
            },
            routeAnalysis.routeModuleExports,
          ] as const;
        }),
      {
        concurrency: Math.max(
          1,
          Math.min(ROUTE_ANALYSIS_CONCURRENCY, Object.keys(routes).length)
        ),
      }
    );

    const routeModuleExportsByRouteId: RouteManifestModuleExports = {};
    for (const [
      key,
      routeManifestItem,
      routeModuleExports,
    ] of manifestEntries) {
      result[key] = routeManifestItem;
      routeModuleExportsByRouteId[key] = routeModuleExports;
    }

    const { js: entryJsAssets, css: entryCssAssets } =
      getAssetsForChunk('entry.client');

    const fingerprintedValues = {
      entry: {
        module: combineURLs(assetPrefix, entryJsAssets[0] || ''),
        imports: entryJsAssets.map(asset => combineURLs(assetPrefix, asset)),
        css: entryCssAssets.map(asset => combineURLs(assetPrefix, asset)),
      },
      routes: result,
    };
    const version = getManifestVersion(fingerprintedValues, isBuild);
    const manifestPath = getReactRouterManifestPath({
      version,
      isBuild,
      entryModulePath: entryJsAssets[0],
    });

    const manifest = {
      version,
      url: combineURLs(assetPrefix, manifestPath),
      hmr: undefined,
      entry: fingerprintedValues.entry,
      sri: undefined,
      routes: result,
    };

    return {
      manifest,
      moduleExportsByRouteId: routeModuleExportsByRouteId,
    };
  });
}

export async function generateReactRouterManifestForDev(
  routes: Record<string, Route>,
  options: PluginOptions,
  clientStats: ReactRouterManifestStats | undefined,
  context: string,
  assetPrefix = '/',
  routeChunkOptions?: RouteChunkManifestOptions
): Promise<ReactRouterManifestGenerationResult> {
  return runPluginEffect(
    generateReactRouterManifestForDevEffect(
      routes,
      options,
      clientStats,
      context,
      assetPrefix,
      routeChunkOptions
    )
  );
}

export async function getReactRouterManifestForDev(
  ...args: Parameters<typeof generateReactRouterManifestForDev>
): Promise<ReactRouterManifestForDev> {
  return (await generateReactRouterManifestForDev(...args)).manifest;
}
