import type { RsbuildDevServer, Rspack } from '@rsbuild/core';
import * as EffectDeferred from 'effect/Deferred';
import * as Effect from 'effect/Effect';
import type { ServerBuild } from 'react-router';
import {
  evaluateServerBuilds,
  getEnvironmentStats,
  isSafeOneSidedChange,
  pinServerBuildsToManifests,
  snapshotDependencies,
  type DependencySnapshot,
  type DevCompilationIdentity,
  type DevGraphChanges,
  type DevGraphIdentity,
  type DevRuntimeStats,
  type ReactRouterDevBuildPlan,
  type ReactRouterDevManifestSet,
  type ReactRouterServerBuilds,
  type WebArtifact,
} from './dev-runtime-artifacts.js';
import { normalizeEffectError, runPluginEffect } from './effect-runtime.js';

export { snapshotDevChangedFiles } from './dev-runtime-artifacts.js';
export type {
  DevChangedFiles,
  DevGraphChanges,
  DevGraphIdentity,
  ReactRouterDevBuildPlan,
  ReactRouterDevManifest,
  ReactRouterDevManifestSet,
} from './dev-runtime-artifacts.js';

type CommittedGeneration = {
  buildsByEntryName: ReactRouterServerBuilds;
  webIdentity: DevCompilationIdentity;
  nodeIdentity: DevCompilationIdentity;
  web: WebArtifact;
  nodeDependencies: DependencySnapshot;
};

type RuntimeState =
  | {
      kind: 'starting';
      attemptId: number;
      readiness: EffectDeferred.Deferred<CommittedGeneration, Error>;
    }
  | { kind: 'failed'; attemptId: number; error: Error }
  | {
      kind: 'ready';
      committed: CommittedGeneration;
      pendingAttemptId: number | null;
    }
  | { kind: 'closed'; error: Error };

export type ReactRouterDevRuntime = {
  beginAttempt: () => void;
  captureWeb: (
    compilation: Rspack.Compilation,
    manifestsByEntryName: ReactRouterDevManifestSet
  ) => void;
  finishAttempt: (
    stats: DevRuntimeStats,
    changes: DevGraphChanges,
    identity: DevGraphIdentity
  ) => Promise<'committed' | 'ignored' | 'retry-node'>;
  failAttempt: (error: Error) => void;
  load: (entryName?: string) => Promise<ServerBuild>;
  close: (error?: Error) => void;
};

type CreateReactRouterDevRuntimeOptions = {
  server: RsbuildDevServer;
  buildPlan: ReactRouterDevBuildPlan;
  onEvaluationError: (error: Error) => void;
  onCssAssetOwnershipChanged?: (change: 'removed' | 'restored') => void;
  onRouteManifestChanged?: () => void;
  onWarning?: (message: string) => void;
};

const collectManifestCssAssetOwnership = (
  manifest: ReactRouterDevManifestSet[string]
): Set<string> => {
  const ownership = new Set<string>();
  for (const asset of manifest.entry?.css ?? []) {
    ownership.add(`entry\0${asset}`);
  }
  for (const [routeId, route] of Object.entries(manifest.routes ?? {})) {
    for (const asset of route.css ?? []) {
      ownership.add(`route\0${routeId}\0${asset}`);
    }
  }
  return ownership;
};

const hasRemovedCssAssetOwnership = (
  previous: ReactRouterDevManifestSet,
  next: ReactRouterDevManifestSet
): boolean => {
  for (const [entryName, previousManifest] of Object.entries(previous)) {
    const previousOwnership =
      collectManifestCssAssetOwnership(previousManifest);
    if (previousOwnership.size === 0) {
      continue;
    }
    const nextManifest = next[entryName];
    if (!nextManifest) {
      return true;
    }
    const nextOwnership = collectManifestCssAssetOwnership(nextManifest);
    for (const owner of previousOwnership) {
      if (!nextOwnership.has(owner)) {
        return true;
      }
    }
  }
  return false;
};

const hasAddedCssAssetOwnership = (
  previous: ReactRouterDevManifestSet,
  next: ReactRouterDevManifestSet
): boolean => hasRemovedCssAssetOwnership(next, previous);

const collectManifestCssAssets = (
  manifest: ReactRouterDevManifestSet[string]
): Set<string> => {
  const assets = new Set(manifest.entry?.css ?? []);
  for (const route of Object.values(manifest.routes ?? {})) {
    for (const asset of route.css ?? []) {
      assets.add(asset);
    }
  }
  return assets;
};

const normalizeManifestForCssOwnershipCheck = (
  manifest: ReactRouterDevManifestSet[string]
) => {
  const cssAssets = collectManifestCssAssets(manifest);
  const nonCssImports = (imports: string[] = []) =>
    imports.filter(importPath => !cssAssets.has(importPath));

  return {
    entry: {
      imports: nonCssImports(manifest.entry?.imports),
      module: manifest.entry?.module,
    },
    routes: Object.fromEntries(
      Object.entries(manifest.routes ?? {})
        .sort(([left], [right]) => left.localeCompare(right))
        .map(([routeId, route]) => [
          routeId,
          {
            caseSensitive: route.caseSensitive,
            clientActionModule: route.clientActionModule,
            clientLoaderModule: route.clientLoaderModule,
            clientMiddlewareModule: route.clientMiddlewareModule,
            errorBoundary: route.hasErrorBoundary,
            hasAction: route.hasAction,
            hasClientAction: route.hasClientAction,
            hasClientLoader: route.hasClientLoader,
            hasClientMiddleware: route.hasClientMiddleware,
            hasDefaultExport: route.hasDefaultExport,
            hasLoader: route.hasLoader,
            hydrateFallbackModule: route.hydrateFallbackModule,
            id: route.id,
            imports: nonCssImports(route.imports),
            index: route.index,
            module: route.module,
            parentId: route.parentId,
            path: route.path,
          },
        ])
    ),
  };
};

const hasOnlyCssAssetOwnershipChanges = (
  previous: ReactRouterDevManifestSet,
  next: ReactRouterDevManifestSet
): boolean => {
  const previousEntryNames = Object.keys(previous).sort();
  const nextEntryNames = Object.keys(next).sort();
  if (previousEntryNames.join('\0') !== nextEntryNames.join('\0')) {
    return false;
  }
  return previousEntryNames.every(entryName => {
    const previousManifest = normalizeManifestForCssOwnershipCheck(
      previous[entryName]
    );
    const nextManifest = normalizeManifestForCssOwnershipCheck(next[entryName]);
    return JSON.stringify(previousManifest) === JSON.stringify(nextManifest);
  });
};

type DevRouteManifestEntry = NonNullable<
  ReactRouterDevManifestSet[string]['routes']
>[string];

const hasSameRouteMetadata = (
  previous: DevRouteManifestEntry,
  next: DevRouteManifestEntry
): boolean =>
  previous.caseSensitive === next.caseSensitive &&
  previous.clientActionModule === next.clientActionModule &&
  previous.clientLoaderModule === next.clientLoaderModule &&
  previous.clientMiddlewareModule === next.clientMiddlewareModule &&
  previous.hasErrorBoundary === next.hasErrorBoundary &&
  previous.hasAction === next.hasAction &&
  previous.hasClientAction === next.hasClientAction &&
  previous.hasClientLoader === next.hasClientLoader &&
  previous.hasClientMiddleware === next.hasClientMiddleware &&
  previous.hasDefaultExport === next.hasDefaultExport &&
  previous.hasLoader === next.hasLoader &&
  previous.hydrateFallbackModule === next.hydrateFallbackModule &&
  previous.id === next.id &&
  previous.index === next.index &&
  previous.parentId === next.parentId &&
  previous.path === next.path;

const hasRouteManifestMetadataChanges = (
  previous: ReactRouterDevManifestSet,
  next: ReactRouterDevManifestSet
): boolean => {
  const previousEntryNames = Object.keys(previous);
  if (previousEntryNames.length !== Object.keys(next).length) {
    return true;
  }
  for (const entryName of previousEntryNames) {
    const nextManifest = next[entryName];
    if (!nextManifest) {
      return true;
    }
    const previousRoutes = previous[entryName].routes ?? {};
    const nextRoutes = nextManifest.routes ?? {};
    const previousRouteIds = Object.keys(previousRoutes);
    if (previousRouteIds.length !== Object.keys(nextRoutes).length) {
      return true;
    }
    for (const routeId of previousRouteIds) {
      const previousRoute = previousRoutes[routeId];
      const nextRoute = nextRoutes[routeId];
      if (!nextRoute || !hasSameRouteMetadata(previousRoute, nextRoute)) {
        return true;
      }
    }
  }
  return false;
};

const createReadinessDeferred = (): EffectDeferred.Deferred<
  CommittedGeneration,
  Error
> => Effect.runSync(EffectDeferred.make<CommittedGeneration, Error>());

export const createReactRouterDevRuntime = ({
  server,
  buildPlan,
  onEvaluationError,
  onCssAssetOwnershipChanged = () => undefined,
  onRouteManifestChanged = () => undefined,
  onWarning = () => undefined,
}: CreateReactRouterDevRuntimeOptions): ReactRouterDevRuntime => {
  let nextAttemptId = 1;
  let reloadAfterCssRemoval = false;
  let state: RuntimeState = {
    kind: 'starting',
    attemptId: 0,
    readiness: createReadinessDeferred(),
  };
  const manifestsByCompilation = new WeakMap<
    Rspack.Compilation,
    ReactRouterDevManifestSet
  >();

  const notifyCssAssetOwnershipChanged = (
    change: 'removed' | 'restored'
  ): void => {
    try {
      onCssAssetOwnershipChanged(change);
    } catch (cause) {
      onWarning(
        `[rsbuild-plugin-react-router] Failed to notify the browser after CSS asset ownership changed: ${normalizeEffectError(cause).message}`
      );
    }
  };

  const notifyRouteManifestChanged = (): void => {
    try {
      onRouteManifestChanged();
    } catch (cause) {
      onWarning(
        `[rsbuild-plugin-react-router] Failed to notify the browser after route manifest metadata changed: ${normalizeEffectError(cause).message}`
      );
    }
  };

  const uniqueEntryNames = new Set(buildPlan.entryNames);
  if (
    uniqueEntryNames.size !== buildPlan.entryNames.length ||
    !uniqueEntryNames.has(buildPlan.defaultEntryName)
  ) {
    throw new Error(
      '[rsbuild-plugin-react-router] The development server build plan must contain unique entries and include its default entry.'
    );
  }

  const selectBuild = (
    generation: CommittedGeneration,
    requestedEntryName?: string
  ): ServerBuild => {
    const entryName = requestedEntryName ?? buildPlan.defaultEntryName;
    const build = generation.buildsByEntryName[entryName];
    if (!build) {
      throw new Error(
        `[rsbuild-plugin-react-router] Committed React Router server build ${JSON.stringify(entryName)} was not found.`
      );
    }
    return build;
  };

  const getCurrentAttemptId = (): number | null => {
    if (state.kind === 'starting') {
      return state.attemptId;
    }
    return state.kind === 'ready' ? state.pendingAttemptId : null;
  };

  const isCurrentAttempt = (attemptId: number): boolean =>
    getCurrentAttemptId() === attemptId;

  const rejectAttempt = (
    attemptId: number,
    error: Error,
    report: boolean
  ): void => {
    if (!isCurrentAttempt(attemptId)) {
      return;
    }
    if (state.kind === 'starting') {
      const { readiness } = state;
      state = { kind: 'failed', attemptId, error };
      Effect.runSync(EffectDeferred.fail(readiness, error));
    } else if (state.kind === 'ready') {
      state = { ...state, pendingAttemptId: null };
    }
    if (report) {
      onEvaluationError(error);
    }
  };

  const commit = (
    attemptId: number,
    committed: CommittedGeneration
  ): boolean => {
    if (!isCurrentAttempt(attemptId)) {
      return false;
    }
    if (state.kind === 'starting') {
      const { readiness } = state;
      state = { kind: 'ready', committed, pendingAttemptId: null };
      Effect.runSync(EffectDeferred.succeed(readiness, committed));
    } else if (state.kind === 'ready') {
      state = { kind: 'ready', committed, pendingAttemptId: null };
    }
    return true;
  };

  const discardUnsafeOneSidedResult = (
    attemptId: number,
    previous: CommittedGeneration,
    webChanged: boolean,
    changes: DevGraphChanges
  ): boolean => {
    const side = webChanged ? 'web-only' : 'node-only';
    const changedFiles = webChanged ? changes.web : changes.node;
    const unchangedDependencies = webChanged
      ? previous.nodeDependencies
      : previous.web.dependencies;
    if (isSafeOneSidedChange(changedFiles, unchangedDependencies)) {
      return false;
    }
    onWarning(
      `[rsbuild-plugin-react-router] Discarded an incomplete ${side} development result and kept the last-good build.`
    );
    rejectAttempt(
      attemptId,
      new Error(`Incomplete ${side} development result.`),
      false
    );
    return true;
  };

  return {
    beginAttempt(): void {
      if (state.kind === 'closed') {
        return;
      }
      const attemptId = nextAttemptId++;
      if (state.kind === 'failed') {
        state = {
          kind: 'starting',
          attemptId,
          readiness: createReadinessDeferred(),
        };
      } else if (state.kind === 'starting') {
        state = { ...state, attemptId };
      } else {
        state = { ...state, pendingAttemptId: attemptId };
      }
    },

    captureWeb(compilation, manifestsByEntryName): void {
      if (state.kind !== 'closed') {
        manifestsByCompilation.set(
          compilation,
          structuredClone(manifestsByEntryName)
        );
      }
    },

    async finishAttempt(stats, changes, identity) {
      const attemptId = getCurrentAttemptId();
      if (attemptId === null) {
        return 'ignored';
      }
      const webStats = getEnvironmentStats(stats, 'web');
      const nodeStats = getEnvironmentStats(stats, 'node');
      if (!webStats || !nodeStats) {
        rejectAttempt(
          attemptId,
          new Error(
            '[rsbuild-plugin-react-router] Development compilation did not provide both web and node results.'
          ),
          true
        );
        return 'ignored';
      }
      if (
        webStats.compilation.needAdditionalPass ||
        nodeStats.compilation.needAdditionalPass
      ) {
        return 'ignored';
      }
      if (webStats.hasErrors() || nodeStats.hasErrors()) {
        rejectAttempt(
          attemptId,
          new Error(
            '[rsbuild-plugin-react-router] The React Router development compilation failed.'
          ),
          false
        );
        return 'ignored';
      }

      const webCompilation = webStats.compilation;
      const nodeCompilation = nodeStats.compilation;
      const webIdentity = identity.web;
      const nodeIdentity = identity.node;
      if (!webIdentity || !nodeIdentity) {
        rejectAttempt(
          attemptId,
          new Error(
            '[rsbuild-plugin-react-router] Development compilation identity was unavailable.'
          ),
          true
        );
        return 'ignored';
      }
      const previous = state.kind === 'ready' ? state.committed : undefined;
      const webChanged = !previous || previous.webIdentity !== webIdentity;
      const nodeChanged = !previous || previous.nodeIdentity !== nodeIdentity;

      if (!webChanged && !nodeChanged) {
        return 'ignored';
      }

      const manifestsByEntryName = webChanged
        ? manifestsByCompilation.get(webCompilation)
        : previous?.web.manifestsByEntryName;
      if (!manifestsByEntryName) {
        rejectAttempt(
          attemptId,
          new Error(
            '[rsbuild-plugin-react-router] The web compilation completed without a matching React Router manifest. Keeping the last-good development build.'
          ),
          true
        );
        return 'ignored';
      }
      const cssAssetsRemoved =
        !!previous &&
        webChanged &&
        hasRemovedCssAssetOwnership(
          previous.web.manifestsByEntryName,
          manifestsByEntryName
        );
      const cssAssetsAdded =
        !!previous &&
        webChanged &&
        hasAddedCssAssetOwnership(
          previous.web.manifestsByEntryName,
          manifestsByEntryName
        );
      const cssOnlyWebManifestChange =
        (cssAssetsRemoved || cssAssetsAdded) &&
        hasOnlyCssAssetOwnershipChanges(
          previous.web.manifestsByEntryName,
          manifestsByEntryName
        );
      const routeManifestMetadataChanged =
        !!previous &&
        webChanged &&
        hasRouteManifestMetadataChanges(
          previous.web.manifestsByEntryName,
          manifestsByEntryName
        );
      const reusePreviousNodeBuild =
        !!previous &&
        cssOnlyWebManifestChange &&
        (!nodeChanged || identity.nodeWeb !== webIdentity);

      if (
        nodeChanged &&
        identity.nodeWeb !== webIdentity &&
        !identity.attempt &&
        !reusePreviousNodeBuild
      ) {
        const message =
          '[rsbuild-plugin-react-router] Discarded web and node results from different compiler cycles and kept the last-good build.';
        if (!previous) {
          return 'retry-node';
        }
        onWarning(message);
        rejectAttempt(attemptId, new Error(message), false);
        return 'retry-node';
      }

      const shouldEvaluateNode = nodeChanged && !reusePreviousNodeBuild;
      if (
        previous &&
        webChanged !== shouldEvaluateNode &&
        !cssOnlyWebManifestChange &&
        discardUnsafeOneSidedResult(attemptId, previous, webChanged, changes)
      ) {
        return 'ignored';
      }

      try {
        const buildsByEntryName = shouldEvaluateNode
          ? await evaluateServerBuilds(server, buildPlan.entryNames)
          : previous!.buildsByEntryName;
        if (!isCurrentAttempt(attemptId)) {
          return 'ignored';
        }
        const web = webChanged
          ? {
              manifestsByEntryName,
              dependencies: snapshotDependencies(webCompilation),
            }
          : previous!.web;
        const committed = commit(attemptId, {
          buildsByEntryName: pinServerBuildsToManifests(
            buildsByEntryName,
            buildPlan.entryNames,
            web.manifestsByEntryName
          ),
          webIdentity,
          nodeIdentity: shouldEvaluateNode
            ? nodeIdentity
            : previous!.nodeIdentity,
          web,
          nodeDependencies: shouldEvaluateNode
            ? snapshotDependencies(nodeCompilation)
            : previous!.nodeDependencies,
        });
        if (!committed) {
          return 'ignored';
        }
        if (cssAssetsRemoved) {
          reloadAfterCssRemoval = !cssAssetsAdded;
          notifyCssAssetOwnershipChanged('removed');
        } else if (cssAssetsAdded) {
          if (reloadAfterCssRemoval) {
            notifyCssAssetOwnershipChanged('restored');
          }
          reloadAfterCssRemoval = false;
        }
        if (routeManifestMetadataChanged) {
          notifyRouteManifestChanged();
        }
        return 'committed';
      } catch (cause) {
        rejectAttempt(attemptId, normalizeEffectError(cause), true);
        return 'ignored';
      }
    },

    failAttempt(error): void {
      const attemptId = getCurrentAttemptId();
      if (attemptId !== null) {
        rejectAttempt(attemptId, error, false);
      }
    },

    load(entryName?: string): Promise<ServerBuild> {
      if (entryName && !uniqueEntryNames.has(entryName)) {
        return Promise.reject(
          new Error(
            `[rsbuild-plugin-react-router] React Router server build ${JSON.stringify(entryName)} is not part of this development server build plan.`
          )
        );
      }
      if (state.kind === 'ready') {
        return Promise.resolve(selectBuild(state.committed, entryName));
      }
      if (state.kind === 'starting') {
        const selected = runPluginEffect(
          EffectDeferred.await(state.readiness).pipe(
            Effect.map(generation => selectBuild(generation, entryName))
          )
        );
        void selected.catch(() => undefined);
        return selected;
      }
      return Promise.reject(state.error);
    },

    close(error?: Error): void {
      if (state.kind === 'closed') {
        return;
      }
      const closeError =
        error ??
        new Error(
          '[rsbuild-plugin-react-router] The development server closed before a React Router build was ready.'
        );
      if (state.kind === 'starting') {
        Effect.runSync(EffectDeferred.fail(state.readiness, closeError));
      }
      state = { kind: 'closed', error: closeError };
    },
  };
};

const DEV_RUNTIME_KEY = Symbol.for(
  'rsbuild-plugin-react-router.dev-runtime.v1'
);

const getRegisteredRuntime = (
  server: RsbuildDevServer
): ReactRouterDevRuntime | undefined =>
  Reflect.get(server, DEV_RUNTIME_KEY) as ReactRouterDevRuntime | undefined;

export const registerReactRouterDevRuntime = (
  server: RsbuildDevServer,
  runtime: ReactRouterDevRuntime
): void => {
  // Symbol.for keeps registration shared when the plugin and public helper are
  // loaded through different ESM and CommonJS package entrypoints.
  Object.defineProperty(server, DEV_RUNTIME_KEY, {
    configurable: true,
    enumerable: false,
    value: runtime,
  });
};

export const unregisterReactRouterDevRuntime = (
  server: RsbuildDevServer,
  runtime: ReactRouterDevRuntime
): void => {
  if (getRegisteredRuntime(server) === runtime) {
    Reflect.deleteProperty(server, DEV_RUNTIME_KEY);
  }
};

export const loadReactRouterServerBuild = (
  server: RsbuildDevServer,
  entryName?: string
): Promise<ServerBuild> => {
  const runtime = getRegisteredRuntime(server);
  if (!runtime) {
    return Promise.reject(
      new Error(
        '[rsbuild-plugin-react-router] This Rsbuild development server is not registered with the React Router plugin. Add pluginReactRouter() before calling loadReactRouterServerBuild().'
      )
    );
  }
  return runtime.load(entryName);
};
