import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import tailwind from 'bun-plugin-tailwind';
import { PLATFORM_DIR } from '../data-path';
import { mountPrefix } from './manifest';
import type { PluginState } from './mount';

// Writing the file the bundler reads, and rebuilding the SPA.
//
// ── Why a generated file at all ──
//
// A bundler cannot follow `import(someRuntimeString)`. The specifier has to be concrete before the build
// runs, so "which plugins have a frontend" cannot be answered from the database at render time — it has
// to be written into source first. That is this file.
//
// `App.tsx` keeps its core routes and gains one map over `plugins`. Everything the shell used to hardcode
// per plugin — the route pair, the screen barrel, the panel metas — collapses into what is generated here.
//
// ── What is NOT here ──
//
// The dock tile. It is permission-filtered, and permission is a runtime question: a grant takes effect on
// the next request, not on the next build. So the tile is served by the self endpoint at request time,
// and this file carries only what the BUNDLER needs.

const GENERATED_PATH = join(PLATFORM_DIR, 'src/apps/officer-web/Plugins.gen.tsx');
const BUILD_DIR = join(PLATFORM_DIR, 'build');
/** Where a build lands before it is swapped into place. Never served. */
const STAGING_DIR = join(PLATFORM_DIR, 'build.next');
const HTML_ENTRY = join(PLATFORM_DIR, 'src/apps/officer-web/index.gen.html');

/**
 * The shell inside `BUILD_DIR`, named after the ENTRYPOINT rather than `index.html`.
 *
 * Bun names an HTML output for its entry file, and `naming: { entry: '[name].[ext]' }` does not change it
 * — `[name]` resolves to `index.gen`. Found by a 503 on the first boot after the switch. Exported so the
 * server reads one constant instead of restating a filename it would get wrong the same way.
 */
export const SHELL_FILE = 'index.gen.html';

/** Import specifier from the generated file's directory to a plugin file, POSIX-style for the bundler. */
function specifier(target: string): string {
  const rel = relative(dirname(GENERATED_PATH), target).split('\\').join('/');
  return (rel.startsWith('.') ? rel : `./${rel}`).replace(/\.tsx?$/, '');
}

const HEADER = `// GENERATED by servers/plugins/generate.ts. Do not edit, and do not commit — it describes what is
// installed on THIS machine, and is rewritten on every install, uninstall, enable and disable.
//
// A bundler cannot follow a runtime string, so the imports below have to be concrete before the build
// runs. That is the whole reason this file exists rather than App.tsx reading the database.
`;

/**
 * Write `Plugins.gen.tsx` for the plugins that are installed, enabled and have a frontend.
 *
 * Always written, even when empty — App.tsx imports it unconditionally, and a missing file is a build
 * error rather than an empty list.
 */
export function generatePluginsModule(states: PluginState[]): string {
  const withWeb = states.filter((s) => s.install?.enabled && s.plugin.web);

  const imports: string[] = [];
  const entries: string[] = [];

  withWeb.forEach((state, i) => {
    const { plugin } = state;
    const alias = `Plugin${i}`;
    imports.push(`import { appRegistryMetas as ${alias}Panels } from '${specifier(plugin.web!.panels)}';`);
    imports.push(`import { defaultLayout as ${alias}Layout } from '${specifier(plugin.web!.layout)}';`);
    entries.push(
      `  { appName: '${plugin.appName}', route: '${mountPrefix(plugin)}', panels: ${alias}Panels, layout: ${alias}Layout },`,
    );
  });

  const body = `${HEADER}
import type { AppRegistryMeta, LayoutNode } from 'officerdev';
${imports.join('\n')}

export type GeneratedPlugin = {
  appName: string;
  /** Where it mounts, from \`mountPrefix\` — \`/offscale\` for ours, \`/p/<publisher>/<name>\` for others. */
  route: string;
  /** The panels it contributes. At least one, or discovery refuses the plugin. */
  panels: AppRegistryMeta[];
  /** How they are arranged. The shell renders \`WorkspaceView\` around them. */
  layout: LayoutNode;
};

export const plugins: GeneratedPlugin[] = [
${entries.join('\n')}
];
`;

  mkdirSync(dirname(GENERATED_PATH), { recursive: true });
  // Only write when it changed: an unchanged file keeps its mtime, which keeps the bundler's caches and
  // `bun --watch` from rebuilding for nothing on every refresh of the plugin list.
  const previous = existsSync(GENERATED_PATH) ? readFileSync(GENERATED_PATH, 'utf-8') : '';
  if (previous !== body) writeFileSync(GENERATED_PATH, body);
  return body;
}

export type BuildResult = { ok: boolean; ms: number; outputs: number; error?: string };

/**
 * Rebuild the SPA into `build/`.
 *
 * ~1s measured on this machine for a 25MB bundle, which is why an install can rebuild rather than asking
 * the owner to restart. Failure is returned rather than thrown: a plugin whose frontend will not compile
 * must leave the previous build in place and serving, not take the UI down with it.
 */
export async function rebuildFrontend(): Promise<BuildResult> {
  const started = Date.now();
  try {
    // Built into a staging directory and swapped, never in place.
    //
    // Two reasons, one of which was learned the hard way. Chunks are content-hashed, so building over the
    // old ones leaves them behind and `build/` grows ~20MB an install — but clearing FIRST means a failed
    // build leaves nothing at all, which is the opposite of what this function promises. And clearing
    // first is a race: two builds overlapping had one process's `rm` delete the other's HTML, leaving a
    // JS and a CSS file with no shell and a 503 that looked like a build failure when the build had
    // succeeded.
    //
    // Staging fixes both. `build/` is only ever replaced by a complete, successful build.
    if (existsSync(STAGING_DIR)) rmSync(STAGING_DIR, { recursive: true, force: true });

    const result = await Bun.build({
      entrypoints: [HTML_ENTRY],
      outdir: STAGING_DIR,
      // WITHOUT THIS THE APP RENDERS UNSTYLED.
      //
      // `bunfig.toml` declares `[serve.static] plugins = ["bun-plugin-tailwind"]`, and that applies to
      // Bun's static SERVING — the HTML-import path — not to a programmatic `Bun.build()`. So the first
      // build produced the xterm CSS and no Tailwind at all: layout intact, every utility class missing.
      // A plugin list is not inherited from bunfig; it has to be passed here.
      plugins: [tailwind],
      minify: process.env.NODE_ENV === 'production',
      sourcemap: 'none',
    });
    if (!result.success) {
      return {
        ok: false,
        ms: Date.now() - started,
        outputs: 0,
        error: result.logs
          .map((l) => String(l))
          .join('; ')
          .slice(0, 500),
      };
    }
    // The shell must exist before anything is swapped in — a build reporting success without one is how
    // the 503 above happened, and serving a directory with no HTML is worse than serving the old one.
    if (!existsSync(join(STAGING_DIR, SHELL_FILE))) {
      return { ok: false, ms: Date.now() - started, outputs: 0, error: `build produced no ${SHELL_FILE}` };
    }

    // Make the shell's asset URLs ABSOLUTE.
    //
    // Bun emits `./chunk-….js` for an HTML entrypoint and `publicPath` does not change it. A relative URL
    // resolves against the CURRENT path, so it is correct at `/` and wrong at `/example/deeper`, where the
    // browser asks for `/example/chunk-….js`. Every deep link more than one segment deep would fail to
    // boot. Rewritten here rather than worked around in the server, because the wrong URL is in the file.
    const shellPath = join(STAGING_DIR, SHELL_FILE);
    const shell = readFileSync(shellPath, 'utf-8');
    const absolute = shell.replace(/(src|href)="\.\//g, '$1="/');
    if (absolute !== shell) writeFileSync(shellPath, absolute);

    if (existsSync(BUILD_DIR)) rmSync(BUILD_DIR, { recursive: true, force: true });
    renameSync(STAGING_DIR, BUILD_DIR);

    return { ok: true, ms: Date.now() - started, outputs: result.outputs.length };
  } catch (err) {
    return {
      ok: false,
      ms: Date.now() - started,
      outputs: 0,
      error: err instanceof Error ? err.message : String(err),
    };
  }
}

export { BUILD_DIR, GENERATED_PATH };
