/**
 * @fileoverview Plugin UI static file serving route
 *
 * Serves plugin UI bundles from the plugin's dist/ui/ directory under the
 * `/_plugins/:pluginId/ui/*` namespace. This is specified in PLUGIN_SPEC.md
 * §19.0.3 (Bundle Serving).
 *
 * Plugin UI bundles are pre-built ESM that the host serves as static assets.
 * The host dynamically imports the plugin's UI entry module from this path,
 * resolves the named export declared in `ui.slots[].exportName`, and mounts
 * it into the extension slot.
 *
 * Security:
 * - Path traversal is prevented by resolving the requested path and verifying
 *   it stays within the plugin's UI directory.
 * - Only plugins in 'ready' status have their UI served.
 * - Only plugins that declare `entrypoints.ui` serve UI bundles.
 *
 * Cache Headers:
 * - Files with content-hash patterns in their name (e.g., `index-a1b2c3d4.js`)
 *   receive `Cache-Control: public, max-age=31536000, immutable`.
 * - Other files receive `Cache-Control: public, max-age=0, must-revalidate`
 *   with ETag-based conditional request support.
 *
 * @module server/routes/plugin-ui-static
 * @see doc/plugins/PLUGIN_SPEC.md §19.0.3 — Bundle Serving
 * @see doc/plugins/PLUGIN_SPEC.md §25.4.5 — Frontend Cache Invalidation
 */

import { Router } from "express";
import path from "node:path";
import fs from "node:fs";
import crypto from "node:crypto";
import type { Db } from "@paperclipai/db";
import { pluginRegistryService } from "../services/plugin-registry.js";
import { logger } from "../middleware/logger.js";

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

/**
 * Regex to detect content-hashed filenames.
 *
 * Matches patterns like:
 * - `index-a1b2c3d4.js`
 * - `styles.abc123def.css`
 * - `chunk-ABCDEF01.mjs`
 *
 * The hash portion must be at least 8 hex characters to avoid false positives.
 */
const CONTENT_HASH_PATTERN = /[.-][a-fA-F0-9]{8,}\.\w+$/;

/**
 * Cache-Control header for content-hashed files.
 * These files are immutable by definition (the hash changes when content changes).
 */
/** 1 year in seconds — standard for content-hashed immutable resources. */
const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60; // 31_536_000
const CACHE_CONTROL_IMMUTABLE = `public, max-age=${ONE_YEAR_SECONDS}, immutable`;

/**
 * Cache-Control header for non-hashed files.
 * These files must be revalidated on each request (ETag-based).
 */
const CACHE_CONTROL_REVALIDATE = "public, max-age=0, must-revalidate";

/**
 * MIME types for common plugin UI bundle file extensions.
 */
const MIME_TYPES: Record<string, string> = {
  ".js": "application/javascript; charset=utf-8",
  ".mjs": "application/javascript; charset=utf-8",
  ".css": "text/css; charset=utf-8",
  ".json": "application/json; charset=utf-8",
  ".map": "application/json; charset=utf-8",
  ".html": "text/html; charset=utf-8",
  ".svg": "image/svg+xml",
  ".png": "image/png",
  ".jpg": "image/jpeg",
  ".jpeg": "image/jpeg",
  ".gif": "image/gif",
  ".webp": "image/webp",
  ".woff": "font/woff",
  ".woff2": "font/woff2",
  ".ttf": "font/ttf",
  ".eot": "application/vnd.ms-fontobject",
  ".ico": "image/x-icon",
  ".txt": "text/plain; charset=utf-8",
};

// ---------------------------------------------------------------------------
// Helper
// ---------------------------------------------------------------------------

/**
 * Resolve a plugin's UI directory from its package location.
 *
 * The plugin's `packageName` is stored in the DB. We resolve the package path
 * from the local plugin directory (DEFAULT_LOCAL_PLUGIN_DIR) by looking in
 * `node_modules`. If the plugin was installed from a local path, the manifest
 * `entrypoints.ui` path is resolved relative to the package directory.
 *
 * @param localPluginDir - The plugin installation directory
 * @param packageName - The npm package name
 * @param entrypointsUi - The UI entrypoint path from the manifest (e.g., "./dist/ui/")
 * @returns Absolute path to the UI directory, or null if not found
 */
export function resolvePluginUiDir(
  localPluginDir: string,
  packageName: string,
  entrypointsUi: string,
  packagePath?: string | null,
): string | null {
  // For local-path installs, prefer the persisted package path.
  if (packagePath) {
    const resolvedPackagePath = path.resolve(packagePath);
    if (fs.existsSync(resolvedPackagePath)) {
      const uiDirFromPackagePath = path.resolve(resolvedPackagePath, entrypointsUi);
      if (
        uiDirFromPackagePath.startsWith(resolvedPackagePath)
        && fs.existsSync(uiDirFromPackagePath)
      ) {
        return uiDirFromPackagePath;
      }
    }
  }

  // Resolve the package root within the local plugin directory's node_modules.
  // npm installs go to <localPluginDir>/node_modules/<packageName>/
  let packageRoot: string;
  if (packageName.startsWith("@")) {
    // Scoped package: @scope/name -> node_modules/@scope/name
    packageRoot = path.join(localPluginDir, "node_modules", ...packageName.split("/"));
  } else {
    packageRoot = path.join(localPluginDir, "node_modules", packageName);
  }

  // If the standard location doesn't exist, the plugin may have been installed
  // from a local path. Try to check if the package.json is accessible at the
  // computed path or if the package is found elsewhere.
  if (!fs.existsSync(packageRoot)) {
    // For local-path installs, the packageName may be a directory that doesn't
    // live inside node_modules. Check if the package exists directly at the
    // localPluginDir level.
    const directPath = path.join(localPluginDir, packageName);
    if (fs.existsSync(directPath)) {
      packageRoot = directPath;
    } else {
      return null;
    }
  }

  // Resolve the UI directory relative to the package root
  const uiDir = path.resolve(packageRoot, entrypointsUi);

  // Verify the resolved UI directory exists and is actually inside the package
  if (!fs.existsSync(uiDir)) {
    return null;
  }

  return uiDir;
}

/**
 * Compute an ETag from file stat (size + mtime).
 * This is a lightweight approach that avoids reading the file content.
 */
function computeETag(size: number, mtimeMs: number): string {
  const ETAG_VERSION = "v2";
  const hash = crypto
    .createHash("md5")
    .update(`${ETAG_VERSION}:${size}-${mtimeMs}`)
    .digest("hex")
    .slice(0, 16);
  return `"${hash}"`;
}

// ---------------------------------------------------------------------------
// Route factory
// ---------------------------------------------------------------------------

/**
 * Options for the plugin UI static route.
 */
export interface PluginUiStaticRouteOptions {
  /**
   * The local plugin installation directory.
   * This is where plugins are installed via `npm install --prefix`.
   * Defaults to the standard `~/.paperclip/plugins/` location.
   */
  localPluginDir: string;
}

/**
 * Create an Express router that serves plugin UI static files.
 *
 * This route handles `GET /_plugins/:pluginId/ui/*` requests by:
 * 1. Looking up the plugin in the registry by ID or key
 * 2. Verifying the plugin is in 'ready' status with UI declared
 * 3. Resolving the file path within the plugin's dist/ui/ directory
 * 4. Serving the file with appropriate cache headers
 *
 * @param db - Database connection for plugin registry lookups
 * @param options - Configuration options
 * @returns Express router
 */
export function pluginUiStaticRoutes(db: Db, options: PluginUiStaticRouteOptions) {
  const router = Router();
  const registry = pluginRegistryService(db);
  const log = logger.child({ service: "plugin-ui-static" });

  /**
   * GET /_plugins/:pluginId/ui/*
   *
   * Serve a static file from a plugin's UI bundle directory.
   *
   * The :pluginId parameter accepts either:
   * - Database UUID
   * - Plugin key (e.g., "acme.linear")
   *
   * The wildcard captures the relative file path within the UI directory.
   *
   * Cache strategy:
   * - Content-hashed filenames → immutable, 1-year max-age
   * - Other files → must-revalidate with ETag
   */
  router.get("/_plugins/:pluginId/ui/*filePath", async (req, res) => {
    const { pluginId } = req.params;

    // Extract the relative file path from the named wildcard.
    // In Express 5 with path-to-regexp v8, named wildcards may return
    // an array of path segments or a single string.
    const rawParam = req.params.filePath;
    const rawFilePath = Array.isArray(rawParam)
      ? rawParam.join("/")
      : rawParam as string | undefined;

    if (!rawFilePath || rawFilePath.length === 0) {
      res.status(400).json({ error: "File path is required" });
      return;
    }

    // Step 1: Look up the plugin
    let plugin = null;
    try {
      plugin = await registry.getById(pluginId);
    } catch (error) {
      const maybeCode =
        typeof error === "object" && error !== null && "code" in error
          ? (error as { code?: unknown }).code
          : undefined;
      if (maybeCode !== "22P02") {
        throw error;
      }
    }
    if (!plugin) {
      plugin = await registry.getByKey(pluginId);
    }

    if (!plugin) {
      res.status(404).json({ error: "Plugin not found" });
      return;
    }

    // Step 2: Verify the plugin is ready and has UI declared
    if (plugin.status !== "ready") {
      res.status(403).json({
        error: `Plugin UI is not available (status: ${plugin.status})`,
      });
      return;
    }

    const manifest = plugin.manifestJson;
    if (!manifest?.entrypoints?.ui) {
      res.status(404).json({ error: "Plugin does not declare a UI bundle" });
      return;
    }

    // Step 2b: Check for devUiUrl in plugin config — proxy to local dev server
    // when a plugin author has configured a dev server URL for hot-reload.
    // See PLUGIN_SPEC.md §27.2 — Local Development Workflow
    try {
      const configRow = await registry.getConfig(plugin.id);
      const devUiUrl =
        configRow &&
        typeof configRow === "object" &&
        "configJson" in configRow &&
        (configRow as { configJson: Record<string, unknown> }).configJson?.devUiUrl;

      if (typeof devUiUrl === "string" && devUiUrl.length > 0) {
        // Dev proxy is only available in development mode
        if (process.env.NODE_ENV === "production") {
          log.warn(
            { pluginId: plugin.id },
            "plugin-ui-static: devUiUrl ignored in production",
          );
          // Fall through to static file serving below
        } else {
          // Guard against rawFilePath overriding the base URL via protocol
          // scheme (e.g. "https://evil.com/x") or protocol-relative paths
          // (e.g. "//evil.com/x") which cause `new URL(path, base)` to
          // ignore the base entirely.
          // Normalize percent-encoding so encoded slashes (%2F) can't bypass
          // the protocol/path checks below.
          let decodedPath: string;
          try {
            decodedPath = decodeURIComponent(rawFilePath);
          } catch {
            res.status(400).json({ error: "Invalid file path" });
            return;
          }
          if (
            decodedPath.includes("://") ||
            decodedPath.startsWith("//") ||
            decodedPath.startsWith("\\\\")
          ) {
            res.status(400).json({ error: "Invalid file path" });
            return;
          }

          // Proxy the request to the dev server
          const targetUrl = new URL(rawFilePath, devUiUrl.endsWith("/") ? devUiUrl : devUiUrl + "/");

          // SSRF protection: only allow http/https and localhost targets for dev proxy
          if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") {
            res.status(400).json({ error: "devUiUrl must use http or https protocol" });
            return;
          }

          // Dev proxy is restricted to loopback addresses only.
          // Validate the *constructed* targetUrl hostname (not the base) to
          // catch any path-based override that slipped past the checks above.
          const devHost = targetUrl.hostname;
          const isLoopback =
            devHost === "localhost" ||
            devHost === "127.0.0.1" ||
            devHost === "::1" ||
            devHost === "[::1]";
          if (!isLoopback) {
            log.warn(
              { pluginId: plugin.id, devUiUrl, host: devHost },
              "plugin-ui-static: devUiUrl must target localhost, rejecting proxy",
            );
            res.status(400).json({ error: "devUiUrl must target localhost" });
            return;
          }

          log.debug(
            { pluginId: plugin.id, devUiUrl, targetUrl: targetUrl.href },
            "plugin-ui-static: proxying to devUiUrl",
          );

          try {
            const controller = new AbortController();
            const timeout = setTimeout(() => controller.abort(), 10_000);
            try {
              const upstream = await fetch(targetUrl.href, { signal: controller.signal });
              if (!upstream.ok) {
                res.status(upstream.status).json({
                  error: `Dev server returned ${upstream.status}`,
                });
                return;
              }

              const contentType = upstream.headers.get("content-type");
              if (contentType) res.set("Content-Type", contentType);
              res.set("Cache-Control", "no-cache, no-store, must-revalidate");

              const body = await upstream.arrayBuffer();
              res.send(Buffer.from(body));
              return;
            } finally {
              clearTimeout(timeout);
            }
          } catch (proxyErr) {
            log.warn(
              {
                pluginId: plugin.id,
                devUiUrl,
                err: proxyErr instanceof Error ? proxyErr.message : String(proxyErr),
              },
              "plugin-ui-static: failed to proxy to devUiUrl, falling back to static",
            );
            // Fall through to static serving below
          }
        }
      }
    } catch {
      // Config lookup failure is non-fatal — fall through to static serving
    }

    // Step 3: Resolve the plugin's UI directory
    const uiDir = resolvePluginUiDir(
      options.localPluginDir,
      plugin.packageName,
      manifest.entrypoints.ui,
      plugin.packagePath,
    );

    if (!uiDir) {
      log.warn(
        { pluginId: plugin.id, pluginKey: plugin.pluginKey, packageName: plugin.packageName },
        "plugin-ui-static: UI directory not found on disk",
      );
      res.status(404).json({ error: "Plugin UI directory not found" });
      return;
    }

    // Step 4: Resolve the requested file path and prevent traversal (including symlinks)
    const resolvedFilePath = path.resolve(uiDir, rawFilePath);

    // Step 5: Check that the file exists and is a regular file
    let fileStat: fs.Stats;
    try {
      fileStat = fs.statSync(resolvedFilePath);
    } catch {
      res.status(404).json({ error: "File not found" });
      return;
    }

    // Security: resolve symlinks via realpathSync and verify containment.
    // This prevents symlink-based traversal that string-based startsWith misses.
    let realFilePath: string;
    let realUiDir: string;
    try {
      realFilePath = fs.realpathSync(resolvedFilePath);
      realUiDir = fs.realpathSync(uiDir);
    } catch {
      res.status(404).json({ error: "File not found" });
      return;
    }

    const relative = path.relative(realUiDir, realFilePath);
    if (relative.startsWith("..") || path.isAbsolute(relative)) {
      res.status(403).json({ error: "Access denied" });
      return;
    }

    if (!fileStat.isFile()) {
      res.status(404).json({ error: "File not found" });
      return;
    }

    // Step 6: Determine cache strategy based on filename
    const basename = path.basename(resolvedFilePath);
    const isContentHashed = CONTENT_HASH_PATTERN.test(basename);

    // Step 7: Set cache headers
    if (isContentHashed) {
      res.set("Cache-Control", CACHE_CONTROL_IMMUTABLE);
    } else {
      res.set("Cache-Control", CACHE_CONTROL_REVALIDATE);

      // Compute and set ETag for conditional request support
      const etag = computeETag(fileStat.size, fileStat.mtimeMs);
      res.set("ETag", etag);

      // Check If-None-Match for 304 Not Modified
      const ifNoneMatch = req.headers["if-none-match"];
      if (ifNoneMatch === etag) {
        res.status(304).end();
        return;
      }
    }

    // Step 8: Set Content-Type
    const ext = path.extname(resolvedFilePath).toLowerCase();
    const contentType = MIME_TYPES[ext];
    if (contentType) {
      res.set("Content-Type", contentType);
    }

    // Step 9: Set CORS headers (plugin UI may be loaded from different origin in dev)
    res.set("Access-Control-Allow-Origin", "*");

    // Step 10: Send the file
    // The plugin source can live in Git worktrees (e.g. ".worktrees/...").
    // `send` defaults to dotfiles:"ignore", which treats dot-directories as
    // not found. We already enforce traversal safety above, so allow dot paths.
    res.sendFile(resolvedFilePath, { dotfiles: "allow" }, (err) => {
      if (err) {
        log.error(
          { err, pluginId: plugin.id, filePath: resolvedFilePath },
          "plugin-ui-static: error sending file",
        );
        // Only send error if headers haven't been sent yet
        if (!res.headersSent) {
          res.status(500).json({ error: "Failed to serve file" });
        }
      }
    });
  });

  return router;
}
