// Discovers and copies static assets declared by bundled extension packages.
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";

function toPosixPath(value) {
  return String(value ?? "").replaceAll("\\", "/");
}

function readJsonFile(filePath, fsImpl) {
  return JSON.parse(fsImpl.readFileSync(filePath, "utf8"));
}

function normalizePackageRelativePath(value) {
  const normalized = toPosixPath(value)
    .trim()
    .replace(/^\.\/+/u, "");
  if (!normalized || normalized.startsWith("../") || normalized.includes("/../")) {
    return "";
  }
  return normalized;
}

function listTrackedExtensionPackageDirs(rootDir, fsImpl) {
  if (fsImpl !== fs) {
    return null;
  }
  const result = spawnSync("git", ["ls-files", "--", ":(glob)extensions/*/package.json"], {
    cwd: rootDir,
    encoding: "utf8",
    stdio: ["ignore", "pipe", "ignore"],
  });
  if (result.status !== 0) {
    return null;
  }
  const deletedResult = spawnSync(
    "git",
    ["ls-files", "--deleted", "--", ":(glob)extensions/*/package.json"],
    {
      cwd: rootDir,
      encoding: "utf8",
      stdio: ["ignore", "pipe", "ignore"],
    },
  );
  if (deletedResult.status !== 0) {
    return null;
  }
  const deletedPaths = new Set(
    deletedResult.stdout.split("\n").map((line) => toPosixPath(line.trim())),
  );
  return result.stdout
    .split("\n")
    .map((line) => toPosixPath(line.trim()))
    .filter((line) => line.length > 0 && !deletedPaths.has(line))
    .flatMap((line) => {
      const match = /^extensions\/([^/]+)\/package\.json$/u.exec(line);
      if (!match?.[1]) {
        return [];
      }
      const packageDir = path.join(rootDir, "extensions", match[1]);
      return [
        {
          dirName: match[1],
          hasPackageJson: true,
          packageDir,
          packageJsonPath: path.join(packageDir, "package.json"),
        },
      ];
    })
    .toSorted((left, right) => left.dirName.localeCompare(right.dirName));
}

function listFilesystemExtensionPackageDirs(rootDir, fsImpl) {
  const extensionsRoot = path.join(rootDir, "extensions");
  if (!fsImpl.existsSync(extensionsRoot)) {
    return [];
  }
  return fsImpl
    .readdirSync(extensionsRoot, { withFileTypes: true })
    .filter((entry) => entry.isDirectory())
    .map((entry) => ({
      dirName: entry.name,
      hasPackageJson: undefined,
      packageDir: path.join(extensionsRoot, entry.name),
      packageJsonPath: path.join(extensionsRoot, entry.name, "package.json"),
    }))
    .toSorted((left, right) => left.dirName.localeCompare(right.dirName));
}

function listExtensionPackageDirs(rootDir, fsImpl) {
  return (
    listTrackedExtensionPackageDirs(rootDir, fsImpl) ??
    listFilesystemExtensionPackageDirs(rootDir, fsImpl)
  );
}

function listDistExtensionPackageDirs(rootDir, fsImpl) {
  const extensionsRoot = path.join(rootDir, "dist", "extensions");
  if (!fsImpl.existsSync(extensionsRoot)) {
    return [];
  }
  return fsImpl
    .readdirSync(extensionsRoot, { withFileTypes: true })
    .filter((entry) => entry.isDirectory() && entry.name !== "node_modules")
    .map((entry) => ({
      dirName: entry.name,
      packageDir: path.join(extensionsRoot, entry.name),
    }))
    .toSorted((left, right) => left.dirName.localeCompare(right.dirName));
}

function readPackageStaticAssetEntries(packageJson) {
  const entries = packageJson.openclaw?.build?.staticAssets;
  return Array.isArray(entries) ? entries : [];
}

function hasPackageAssetBuild(packageJson) {
  const command = packageJson.openclaw?.assetScripts?.build;
  return typeof command === "string" && command.trim().length > 0;
}

function readPackageGeneratedAssetOutputEntries(packageJson) {
  const entries = packageJson.openclaw?.assetScripts?.buildOutputs;
  return Array.isArray(entries) ? entries : [];
}

// External plugins (`bundledDist: false`) own their own dist and are excluded
// from core dist, so their static assets must not be discovered for core
// runtime postbuild copies.
function isExternalDistPackage(packageJson) {
  return packageJson.openclaw?.build?.bundledDist === false;
}

/**
 * Discovers static asset copy specs from extension package metadata.
 *
 * External plugins (`bundledDist: false`) are skipped by default so their
 * launchers are not copied into core dist. Per-package plugin builds pass
 * `includeExternalPlugins` to still emit their own static assets.
 */
export function discoverStaticExtensionAssets(params = {}) {
  const rootDir = params.rootDir ?? process.cwd();
  const fsImpl = params.fs ?? fs;
  const includeExternalPlugins = params.includeExternalPlugins ?? false;
  const assets = [];
  for (const { dirName, hasPackageJson, packageJsonPath } of listExtensionPackageDirs(
    rootDir,
    fsImpl,
  )) {
    if (!(hasPackageJson ?? fsImpl.existsSync(packageJsonPath))) {
      continue;
    }
    const packageJson = readJsonFile(packageJsonPath, fsImpl);
    if (!includeExternalPlugins && isExternalDistPackage(packageJson)) {
      continue;
    }
    for (const entry of readPackageStaticAssetEntries(packageJson)) {
      const source = normalizePackageRelativePath(entry?.source);
      const output = normalizePackageRelativePath(entry?.output);
      if (!source || !output) {
        continue;
      }
      assets.push({
        pluginDir: dirName,
        src: toPosixPath(path.posix.join("extensions", dirName, source)),
        dest: toPosixPath(path.posix.join("dist", "extensions", dirName, output)),
      });
    }
  }
  return assets.toSorted((left, right) => left.dest.localeCompare(right.dest));
}

function discoverStaticExtensionRuntimeOverlayAssets(params = {}) {
  const rootDir = params.rootDir ?? process.cwd();
  const fsImpl = params.fs ?? fs;
  const assetsByDest = new Map();
  for (const asset of params.assets ?? discoverStaticExtensionAssets({ rootDir, fs: fsImpl })) {
    assetsByDest.set(asset.dest, asset);
  }
  for (const { dirName, packageDir } of listDistExtensionPackageDirs(rootDir, fsImpl)) {
    const packageJsonPath = path.join(packageDir, "package.json");
    if (!fsImpl.existsSync(packageJsonPath)) {
      continue;
    }
    const packageJson = readJsonFile(packageJsonPath, fsImpl);
    for (const entry of readPackageStaticAssetEntries(packageJson)) {
      const output = normalizePackageRelativePath(entry?.output);
      if (!output) {
        continue;
      }
      const dest = toPosixPath(path.posix.join("dist", "extensions", dirName, output));
      if (!assetsByDest.has(dest)) {
        assetsByDest.set(dest, { pluginDir: dirName, src: dest, dest });
      }
    }
  }
  return [...assetsByDest.values()].toSorted((left, right) => left.dest.localeCompare(right.dest));
}

/**
 * Lists generated dist output paths for declared static extension assets.
 */
export function listStaticExtensionAssetOutputs(params = {}) {
  const assets = params.assets ?? discoverStaticExtensionAssets(params);
  return assets
    .map(({ dest }) => dest.replace(/\\/g, "/"))
    .toSorted((left, right) => left.localeCompare(right));
}

/**
 * Lists source file paths for declared static extension assets.
 */
export function listStaticExtensionAssetSources(params = {}) {
  const assets = params.assets ?? discoverStaticExtensionAssets(params);
  return assets
    .map(({ src }) => src.replace(/\\/g, "/"))
    .toSorted((left, right) => left.localeCompare(right));
}

/**
 * Lists source-tree outputs generated by extension asset build hooks.
 */
export function listGeneratedExtensionAssetSources(params = {}) {
  const rootDir = params.rootDir ?? process.cwd();
  const fsImpl = params.fs ?? fs;
  const sources = new Set();
  for (const { dirName, hasPackageJson, packageJsonPath } of listFilesystemExtensionPackageDirs(
    rootDir,
    fsImpl,
  )) {
    if (!(hasPackageJson ?? fsImpl.existsSync(packageJsonPath))) {
      continue;
    }
    const packageJson = readJsonFile(packageJsonPath, fsImpl);
    if (!hasPackageAssetBuild(packageJson)) {
      continue;
    }

    const packageSources = [
      ...readPackageStaticAssetEntries(packageJson).map((entry) => entry?.source),
      ...readPackageGeneratedAssetOutputEntries(packageJson),
    ];
    for (const entry of packageSources) {
      const source = normalizePackageRelativePath(entry);
      if (source) {
        sources.add(toPosixPath(path.posix.join("extensions", dirName, source)));
      }
    }
  }
  return [...sources].toSorted((left, right) => left.localeCompare(right));
}

/**
 * Copies declared static extension assets from source packages into root dist.
 */
export function copyStaticExtensionAssets(params = {}) {
  const rootDir = params.rootDir ?? process.cwd();
  const fsImpl = params.fs ?? fs;
  const assets = params.assets ?? discoverStaticExtensionAssets({ rootDir, fs: fsImpl });
  const warn = params.warn ?? console.warn;
  for (const { src, dest } of assets) {
    const srcPath = path.join(rootDir, src);
    const destPath = path.join(rootDir, dest);
    if (fsImpl.existsSync(srcPath)) {
      fsImpl.mkdirSync(path.dirname(destPath), { recursive: true });
      fsImpl.copyFileSync(srcPath, destPath);
    } else {
      warn(`[runtime-postbuild] static asset not found, skipping: ${src}`);
    }
  }
}

/**
 * Copies static assets into the dist-runtime overlay from source or root dist.
 */
export function copyStaticExtensionAssetsToRuntimeOverlay(params = {}) {
  const rootDir = params.rootDir ?? process.cwd();
  const fsImpl = params.fs ?? fs;
  const assets = discoverStaticExtensionRuntimeOverlayAssets({ ...params, rootDir, fs: fsImpl });
  const runtimeExtensionsRoot = path.join(rootDir, "dist-runtime", "extensions");
  if (!fsImpl.existsSync(runtimeExtensionsRoot)) {
    return;
  }
  const warn = params.warn ?? console.warn;
  for (const { src, dest } of assets) {
    const normalizedDest = toPosixPath(dest);
    if (!normalizedDest.startsWith("dist/extensions/")) {
      continue;
    }
    const srcPath = path.join(rootDir, src);
    const distPath = path.join(rootDir, dest);
    const copySourcePath = fsImpl.existsSync(srcPath) ? srcPath : distPath;
    const destPath = path.join(rootDir, "dist-runtime", normalizedDest.slice("dist/".length));
    if (fsImpl.existsSync(copySourcePath)) {
      fsImpl.mkdirSync(path.dirname(destPath), { recursive: true });
      fsImpl.copyFileSync(copySourcePath, destPath);
    } else {
      warn(`[runtime-postbuild] static asset not found, skipping: ${src}`);
    }
  }
}

/**
 * Copies declared static assets for one package runtime build.
 */
export function copyStaticExtensionAssetsForPackage(params) {
  const rootDir = params.rootDir ?? process.cwd();
  const fsImpl = params.fs ?? fs;
  const assets =
    params.assets ??
    discoverStaticExtensionAssets({ rootDir, fs: fsImpl, includeExternalPlugins: true });
  const packagePrefix = `extensions/${params.pluginDir}/`;
  const rootDistPrefix = `dist/extensions/${params.pluginDir}/`;
  const copied = [];
  for (const { src, dest } of assets) {
    const normalizedSrc = src.replaceAll("\\", "/");
    const normalizedDest = dest.replaceAll("\\", "/");
    if (!normalizedSrc.startsWith(packagePrefix) || !normalizedDest.startsWith(rootDistPrefix)) {
      continue;
    }
    const srcPath = path.join(rootDir, src);
    if (!fsImpl.existsSync(srcPath)) {
      continue;
    }
    const packageRelativeDest = normalizedDest.slice(rootDistPrefix.length);
    const destPath = path.join(rootDir, packagePrefix, "dist", packageRelativeDest);
    fsImpl.mkdirSync(path.dirname(destPath), { recursive: true });
    fsImpl.copyFileSync(srcPath, destPath);
    copied.push(`dist/${packageRelativeDest}`);
  }
  return copied.toSorted((left, right) => left.localeCompare(right));
}
