// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { createHash } from 'node:crypto';
import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync, readdirSync } from 'node:fs';
import { dirname, basename, resolve as resolvePath, relative, sep as PATH_SEP } from 'node:path';

/**
 * Build a single ESM bundle that re-exports every inline handler function
 * collected by `lowerCallables`. The bundle is the second half of the
 * "two-product" build (alongside `objectstack.json`):
 *
 *   dist/
 *   ├── objectstack.json
 *   └── objectstack-runtime.{contenthash}.mjs   ← THIS
 *
 * At runtime, `StandaloneStack` (and `MultiProjectPlugin`) dynamic-import
 * the mjs and merge `module.functions` into the bundle before
 * `bindHooks(...)` runs — so string-named handlers resolve to real code
 * even though the JSON artifact is pure metadata.
 */

export interface BuildRuntimeOptions {
  /** Absolute path to the user's `objectstack.config.{ts,js,mjs}`. */
  sourceConfigPath: string;
  /** Lowered handler refs (`name → handlerName`) — mirrors `LoweringResult.functions`. */
  refs: string[];
  /** Output directory (e.g. `<cwd>/dist`). Created if missing. */
  outputDir: string;
  /** Base file name without hash, e.g. `objectstack-runtime`. */
  baseName?: string;
  /** External package names to mark as external (`@objectstack/*` etc.). */
  externalPackages?: string[];
}

export interface BuildRuntimeResult {
  /** Absolute path to the emitted mjs. */
  outputPath: string;
  /** File name (relative to outputDir) — embedded in objectstack.json. */
  outputFileName: string;
  /** Content hash (sha256, first 16 hex chars). */
  hash: string;
  /** Bundle size in bytes. */
  size: number;
}

const DEFAULT_BASE = 'objectstack-runtime';

/**
 * Generate the entry source that re-extracts callable handlers from the
 * compiled config. We bundle the user's config transitively (so every
 * `*.hook.ts` import is captured), then walk the same shape the lowering
 * pass walked and re-export the matching handlers under the same string
 * keys. The runtime side merges this map into `bundle.functions`.
 */
function generateEntrySource(sourceConfigPath: string, refs: string[], entryDir: string): string {
  // Use a POSIX-style relative path so esbuild's resolver bundles the
  // user's config + every transitively-imported `*.hook.ts` into the
  // emitted ESM. A `file://` URL would be treated as external.
  let importPath = relative(entryDir, sourceConfigPath).split(PATH_SEP).join('/');
  if (!importPath.startsWith('.')) importPath = './' + importPath;
  const refLiteral = JSON.stringify(refs);

  return `// AUTO-GENERATED by @objectstack/cli — do not edit.
import config from ${JSON.stringify(importPath)};

const REFS = new Set(${refLiteral});

function collect(cfg) {
  const out = Object.create(null);
  if (!cfg || typeof cfg !== 'object') return out;
  // Hooks: { name, handler }
  if (Array.isArray(cfg.hooks)) {
    for (const h of cfg.hooks) {
      if (h && typeof h === 'object' && typeof h.handler === 'function' && typeof h.name === 'string') {
        if (REFS.has(h.name)) out[h.name] = h.handler;
      }
    }
  }
  // Top-level functions map
  if (cfg.functions && !Array.isArray(cfg.functions) && typeof cfg.functions === 'object') {
    for (const [k, v] of Object.entries(cfg.functions)) {
      if (typeof v === 'function' && REFS.has(k)) out[k] = v;
    }
  }
  // Top-level functions array
  if (Array.isArray(cfg.functions)) {
    for (const entry of cfg.functions) {
      if (entry && typeof entry === 'object' && typeof entry.handler === 'function' && typeof entry.name === 'string') {
        if (REFS.has(entry.name)) out[entry.name] = entry.handler;
      }
    }
  }
  return out;
}

const cfg = config && typeof config === 'object' && 'default' in config ? config.default : config;
export const functions = collect(cfg);
export const meta = {
  builtAt: new Date().toISOString(),
  refCount: ${refs.length},
};
`;
}

export async function buildRuntimeBundle(opts: BuildRuntimeOptions): Promise<BuildRuntimeResult> {
  const { sourceConfigPath, refs, outputDir } = opts;
  const baseName = opts.baseName ?? DEFAULT_BASE;

  if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });

  const entryDir = dirname(sourceConfigPath);
  const entryPath = resolvePath(
    entryDir,
    `.${baseName}.entry.${process.pid}.${Date.now()}.mjs`,
  );
  writeFileSync(entryPath, generateEntrySource(sourceConfigPath, refs, entryDir));

  let esbuild: typeof import('esbuild');
  try {
    esbuild = await import('esbuild');
  } catch (err) {
    try { unlinkSync(entryPath); } catch { /* noop */ }
    throw new Error(
      `[build-runtime] esbuild is required to bundle declarative handlers but is not installed: ${(err as Error).message}`,
    );
  }

  // Bundle to an in-memory buffer first so we can hash, then rename.
  let result;
  try {
    result = await esbuild.build({
      entryPoints: [entryPath],
      bundle: true,
      format: 'esm',
      platform: 'node',
      target: 'node18',
      write: false,
      sourcemap: 'inline',
      packages: 'external',
      logLevel: 'silent',
      legalComments: 'none',
      resolveExtensions: ['.ts', '.tsx', '.mjs', '.js', '.cjs', '.json'],
      loader: { '.ts': 'ts', '.tsx': 'tsx' },
      banner: { js: '// @generated by @objectstack/cli — do not edit.' },
    });
  } finally {
    try { unlinkSync(entryPath); } catch { /* noop */ }
  }

  const out = result.outputFiles?.[0];
  if (!out) throw new Error('[build-runtime] esbuild produced no output');

  const code = out.text;
  const hash = createHash('sha256').update(code).digest('hex').slice(0, 16);
  const fileName = `${baseName}.${hash}.mjs`;
  const outputPath = resolvePath(outputDir, fileName);
  writeFileSync(outputPath, code);

  return {
    outputPath,
    outputFileName: fileName,
    hash,
    size: Buffer.byteLength(code, 'utf8'),
  };
}

/** Best-effort cleanup of stale `objectstack-runtime.*.mjs` files. */
export function cleanupOldRuntimeBundles(outputDir: string, keepFileName: string, baseName = DEFAULT_BASE): number {
  if (!existsSync(outputDir)) return 0;
  let removed = 0;
  try {
    const re = new RegExp(`^${baseName}\\.[a-f0-9]+\\.mjs(\\.map)?$`);
    for (const f of readdirSync(outputDir)) {
      if (!re.test(f)) continue;
      if (f === keepFileName) continue;
      try {
        unlinkSync(resolvePath(outputDir, f));
        removed += 1;
      } catch { /* noop */ }
    }
  } catch { /* noop */ }
  return removed;
}

/** Reuse: read an existing bundle and recompute its size for stats. */
export function statBundle(outputPath: string): { size: number } | null {
  try {
    const buf = readFileSync(outputPath);
    return { size: buf.byteLength };
  } catch {
    return null;
  }
}

export const runtimeBundlePathRelative = (outputDir: string, file: string): string =>
  relative(outputDir, resolvePath(outputDir, basename(file)));
