import { readFile, readdir, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { createHash } from 'node:crypto';
import path from 'node:path';
import { logger } from '@/lib/logger';
import type { ServerPlugin } from './plugin-registry';
import { sanitizeFrameOrigins, sanitizeHttpOrigins } from './csp-frame-origins';

/**
 * Dev-mode plugin loading.
 *
 * Set PLUGIN_DEV_DIR to a directory whose immediate subfolders are plugin
 * sources. Each subfolder must contain a `manifest.json`. The bundle file
 * (declared as `entrypoint` in the manifest) is resolved in this order:
 *
 *   1. `src/<entrypoint>`  → bundled on-demand via esbuild (preferred).
 *      Lets you edit source files directly and just refresh the browser.
 *   2. `<entrypoint>` at the plugin root → served raw.
 *   3. `dist/<entrypoint>` → served raw (output of a manual build).
 *
 * Bundles are recomputed on every request so any save in `src/` propagates
 * to all connected clients on their next page refresh. The content hash
 * doubles as the HTTP ETag and the `?v=` cache-buster.
 */

export interface DevPluginEntry {
  plugin: ServerPlugin;
  /** Absolute path to either a source file (needs bundling) or a built file. */
  bundlePath: string;
  manifestPath: string;
  /** True when bundlePath points at an unbundled source file under `src/`. */
  needsBundle: boolean;
}

const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;

export function getPluginDevDir(): string | null {
  const dir = process.env.PLUGIN_DEV_DIR;
  if (!dir) return null;
  const resolved = path.resolve(dir);
  if (!existsSync(resolved)) {
    logger.warn(`PLUGIN_DEV_DIR is set but does not exist: ${resolved}`);
    return null;
  }
  return resolved;
}

function asString(v: unknown, fallback = ''): string {
  return typeof v === 'string' ? v : fallback;
}

async function readManifest(manifestPath: string): Promise<Record<string, unknown> | null> {
  try {
    const raw = await readFile(manifestPath, 'utf-8');
    const parsed = JSON.parse(raw);
    return typeof parsed === 'object' && parsed !== null ? parsed : null;
  } catch {
    return null;
  }
}

interface ResolvedBundle {
  bundlePath: string;
  needsBundle: boolean;
}

function resolveBundlePath(pluginDir: string, entrypoint: string): ResolvedBundle | null {
  const srcCandidate = path.join(pluginDir, 'src', entrypoint);
  if (existsSync(srcCandidate)) return { bundlePath: srcCandidate, needsBundle: true };

  const rootCandidate = path.join(pluginDir, entrypoint);
  if (existsSync(rootCandidate)) return { bundlePath: rootCandidate, needsBundle: false };

  const distCandidate = path.join(pluginDir, 'dist', entrypoint);
  if (existsSync(distCandidate)) return { bundlePath: distCandidate, needsBundle: false };

  return null;
}

/**
 * Load and bundle a dev plugin's code. For `src/` sources this runs esbuild
 * on every call so saves are reflected immediately. Errors are surfaced as
 * a JS module that throws at activation time - that way the dev sees the
 * failure in the browser console instead of a silent 404.
 */
export async function readDevBundle(entry: DevPluginEntry): Promise<string> {
  if (!entry.needsBundle) {
    return readFile(entry.bundlePath, 'utf-8');
  }
  try {
    const esbuild = await import('esbuild');
    const result = await esbuild.build({
      entryPoints: [entry.bundlePath],
      bundle: true,
      format: 'esm',
      write: false,
      logLevel: 'silent',
      sourcemap: 'inline',
      target: ['es2020'],
      // React/ReactDOM are exposed on globalThis.__PLUGIN_EXTERNALS__ by the
      // host, so we mark them external - the bundle won't try to ship them.
      external: ['react', 'react-dom', 'react/jsx-runtime'],
    });
    const out = result.outputFiles?.[0]?.text;
    if (!out) throw new Error('esbuild produced no output');
    return out;
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    logger.warn(`[plugin-dev] esbuild failed for ${entry.plugin.id}`, { error: message });
    // Return a module that throws on load so the dev sees the error.
    return `throw new Error(${JSON.stringify(`[plugin-dev:${entry.plugin.id}] esbuild failed: ${message}`)});`;
  }
}

async function loadDevPlugin(pluginDir: string): Promise<DevPluginEntry | null> {
  // Prefer the root manifest.json. Fall back to dist/manifest.json for
  // pre-built plugins that don't keep a manifest at the root.
  let manifestPath = path.join(pluginDir, 'manifest.json');
  if (!existsSync(manifestPath)) {
    manifestPath = path.join(pluginDir, 'dist', 'manifest.json');
  }
  if (!existsSync(manifestPath)) {
    logger.warn(`[plugin-dev] no manifest.json at root or dist/ in ${pluginDir}`);
    return null;
  }

  const manifest = await readManifest(manifestPath);
  if (!manifest) {
    logger.warn(`[plugin-dev] manifest unreadable or not a JSON object: ${manifestPath}`);
    return null;
  }
  const id = asString(manifest.id);
  if (!PLUGIN_ID_RE.test(id)) {
    logger.warn(`[plugin-dev] manifest id "${id}" rejected by id regex (${manifestPath})`);
    return null;
  }

  const entrypoint = asString(manifest.entrypoint, 'index.js');
  const resolved = resolveBundlePath(pluginDir, entrypoint);
  if (!resolved) {
    logger.warn(`[plugin-dev] entrypoint "${entrypoint}" not found at src/, root, or dist/ for ${id}`);
    return null;
  }

  // Hash from the on-disk source so any edit propagates. For src/ sources
  // we hash the source - close enough for dev-time change detection (we
  // don't need to re-hash transitive imports).
  let bundleHash: string;
  try {
    const code = await readFile(resolved.bundlePath);
    bundleHash = createHash('sha256').update(code).digest('hex').slice(0, 16);
  } catch (err) {
    logger.warn(`[plugin-dev] failed to read ${resolved.bundlePath} for ${id}`, {
      error: err instanceof Error ? err.message : String(err),
    });
    return null;
  }

  let installedAt = new Date().toISOString();
  try {
    const stats = await stat(resolved.bundlePath);
    installedAt = stats.mtime.toISOString();
  } catch {
    /* ignore */
  }

  const permissions = Array.isArray(manifest.permissions)
    ? manifest.permissions.filter((p): p is string => typeof p === 'string')
    : [];

  const frameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
  const httpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);

  const plugin: ServerPlugin = {
    id,
    name: asString(manifest.name, id),
    version: asString(manifest.version, '0.0.0-dev'),
    author: asString(manifest.author),
    description: asString(manifest.description),
    type: asString(manifest.type, 'hook'),
    permissions,
    entrypoint,
    enabled: true,
    forceEnabled: false,
    ...(manifest.configSchema && typeof manifest.configSchema === 'object'
      ? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
      : {}),
    ...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
      ? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
      : {}),
    ...(frameOrigins.length > 0 ? { frameOrigins } : {}),
    ...(httpOrigins.length > 0 ? { httpOrigins } : {}),
    installedAt,
    updatedAt: new Date().toISOString(),
    bundleHash,
  };
  return { plugin, bundlePath: resolved.bundlePath, manifestPath, needsBundle: resolved.needsBundle };
}

export async function listDevPlugins(): Promise<DevPluginEntry[]> {
  const dir = getPluginDevDir();
  if (!dir) return [];

  let entries: string[];
  try {
    entries = await readdir(dir);
  } catch (error) {
    logger.warn('Failed to read PLUGIN_DEV_DIR', {
      dir,
      error: error instanceof Error ? error.message : String(error),
    });
    return [];
  }

  const out: DevPluginEntry[] = [];
  for (const name of entries) {
    if (name.startsWith('.') || name === 'node_modules') continue;
    const fullPath = path.join(dir, name);
    let isDir = false;
    try { isDir = (await stat(fullPath)).isDirectory(); } catch { continue; }
    if (!isDir) continue;

    const entry = await loadDevPlugin(fullPath);
    if (entry) out.push(entry);
  }
  return out;
}

export async function getDevPlugin(id: string): Promise<DevPluginEntry | null> {
  if (!PLUGIN_ID_RE.test(id)) return null;
  const list = await listDevPlugins();
  return list.find(e => e.plugin.id === id) ?? null;
}
