import fs from "node:fs";
import path from "node:path";

import { resolveConfigDir, resolveUserPath } from "../utils.js";
import { resolveBundledPluginsDir } from "./bundled-dir.js";
import type { OpenclawPackageManifest, PackageManifest } from "./manifest.js";
import { resolvePackageOpenclawMeta } from "./manifest.js";
import { isPathInside, isPathInsideWithRealpath } from "../security/scan-paths.js";
import type { PluginDiagnostic, PluginOrigin } from "./types.js";

const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]);

export type PluginCandidate = {
  idHint: string;
  source: string;
  rootDir: string;
  origin: PluginOrigin;
  workspaceDir?: string;
  packageName?: string;
  packageVersion?: string;
  packageDescription?: string;
  packageDir?: string;
  packageOpenclaw?: OpenclawPackageManifest;
};

export type PluginDiscoveryResult = {
  candidates: PluginCandidate[];
  diagnostics: PluginDiagnostic[];
};

function isExtensionFile(filePath: string): boolean {
  const ext = path.extname(filePath);
  if (!EXTENSION_EXTS.has(ext)) return false;
  return !filePath.endsWith(".d.ts");
}

function readPackageManifest(dir: string): PackageManifest | null {
  const manifestPath = path.join(dir, "package.json");
  if (!fs.existsSync(manifestPath)) return null;
  try {
    const raw = fs.readFileSync(manifestPath, "utf-8");
    return JSON.parse(raw) as PackageManifest;
  } catch {
    return null;
  }
}

function resolvePackageExtensions(manifest: PackageManifest): string[] {
  const raw = resolvePackageOpenclawMeta(manifest)?.extensions;
  if (!Array.isArray(raw)) return [];
  return raw.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean);
}

function deriveIdHint(params: {
  filePath: string;
  packageName?: string;
  hasMultipleExtensions: boolean;
}): string {
  const base = path.basename(params.filePath, path.extname(params.filePath));
  const rawPackageName = params.packageName?.trim();
  if (!rawPackageName) return base;

  // Prefer the unscoped name so config keys stay stable even when the npm
  // package is scoped (example: @clawdbot/voice-call -> voice-call).
  const unscoped = rawPackageName.includes("/")
    ? (rawPackageName.split("/").pop() ?? rawPackageName)
    : rawPackageName;

  if (!params.hasMultipleExtensions) return unscoped;
  return `${unscoped}/${base}`;
}

function addCandidate(params: {
  candidates: PluginCandidate[];
  seen: Set<string>;
  idHint: string;
  source: string;
  rootDir: string;
  origin: PluginOrigin;
  workspaceDir?: string;
  manifest?: PackageManifest | null;
  packageDir?: string;
}) {
  const resolved = path.resolve(params.source);
  if (params.seen.has(resolved)) return;
  params.seen.add(resolved);
  const manifest = params.manifest ?? null;
  params.candidates.push({
    idHint: params.idHint,
    source: resolved,
    rootDir: path.resolve(params.rootDir),
    origin: params.origin,
    workspaceDir: params.workspaceDir,
    packageName: manifest?.name?.trim() || undefined,
    packageVersion: manifest?.version?.trim() || undefined,
    packageDescription: manifest?.description?.trim() || undefined,
    packageDir: params.packageDir,
    packageOpenclaw: manifest?.openclaw,
  });
}

function discoverInDirectory(params: {
  dir: string;
  origin: PluginOrigin;
  workspaceDir?: string;
  candidates: PluginCandidate[];
  diagnostics: PluginDiagnostic[];
  seen: Set<string>;
}) {
  if (!fs.existsSync(params.dir)) return;
  let entries: fs.Dirent[] = [];
  try {
    entries = fs.readdirSync(params.dir, { withFileTypes: true });
  } catch (err) {
    params.diagnostics.push({
      level: "warn",
      message: `failed to read extensions dir: ${params.dir} (${String(err)})`,
      source: params.dir,
    });
    return;
  }

  for (const entry of entries) {
    const fullPath = path.join(params.dir, entry.name);
    if (entry.isFile()) {
      if (!isExtensionFile(fullPath)) continue;
      addCandidate({
        candidates: params.candidates,
        seen: params.seen,
        idHint: path.basename(entry.name, path.extname(entry.name)),
        source: fullPath,
        rootDir: path.dirname(fullPath),
        origin: params.origin,
        workspaceDir: params.workspaceDir,
      });
    }
    if (!entry.isDirectory()) continue;

    const manifest = readPackageManifest(fullPath);
    const extensions = manifest ? resolvePackageExtensions(manifest) : [];

    if (extensions.length > 0) {
      for (const extPath of extensions) {
        const resolved = path.resolve(fullPath, extPath);
        // Enforce path containment: extension entry must stay inside package directory
        if (!isPathInside(fullPath, resolved)) {
          params.diagnostics.push({
            level: "error",
            message: `openclaw.extensions entry escapes package directory: ${extPath}`,
            source: fullPath,
          });
          continue;
        }
        if (!fs.existsSync(resolved)) {
          // Bundled source-only packages without dist/ are expected – skip silently
          // but still emit a candidate if the package has install metadata (npmSpec),
          // so the channel catalog can offer the npm install prompt.
          const hasInstallMeta = Boolean(resolvePackageOpenclawMeta(manifest!)?.install?.npmSpec);
          if (hasInstallMeta) {
            addCandidate({
              candidates: params.candidates,
              seen: params.seen,
              idHint: deriveIdHint({
                filePath: resolved,
                packageName: manifest?.name,
                hasMultipleExtensions: extensions.length > 1,
              }),
              source: resolved,
              rootDir: fullPath,
              origin: params.origin,
              workspaceDir: params.workspaceDir,
              manifest,
              packageDir: fullPath,
            });
          } else if (params.origin !== "bundled") {
            params.diagnostics.push({
              level: "warn",
              message: `openclaw.extensions entry not found: ${extPath}`,
              source: fullPath,
            });
          }
          continue;
        }
        if (!isPathInsideWithRealpath(fullPath, resolved, { requireRealpath: true })) {
          params.diagnostics.push({
            level: "error",
            message: `openclaw.extensions entry resolves outside package directory: ${extPath}`,
            source: fullPath,
          });
          continue;
        }
        addCandidate({
          candidates: params.candidates,
          seen: params.seen,
          idHint: deriveIdHint({
            filePath: resolved,
            packageName: manifest?.name,
            hasMultipleExtensions: extensions.length > 1,
          }),
          source: resolved,
          rootDir: fullPath,
          origin: params.origin,
          workspaceDir: params.workspaceDir,
          manifest,
          packageDir: fullPath,
        });
      }
      continue;
    }

    const indexCandidates = ["index.ts", "index.js", "index.mjs", "index.cjs"];
    const indexFile = indexCandidates
      .map((candidate) => path.join(fullPath, candidate))
      .find((candidate) => fs.existsSync(candidate));
    if (indexFile && isExtensionFile(indexFile)) {
      addCandidate({
        candidates: params.candidates,
        seen: params.seen,
        idHint: entry.name,
        source: indexFile,
        rootDir: fullPath,
        origin: params.origin,
        workspaceDir: params.workspaceDir,
        manifest,
        packageDir: fullPath,
      });
    }
  }
}

function discoverFromPath(params: {
  rawPath: string;
  origin: PluginOrigin;
  workspaceDir?: string;
  candidates: PluginCandidate[];
  diagnostics: PluginDiagnostic[];
  seen: Set<string>;
}) {
  const resolved = resolveUserPath(params.rawPath);
  if (!fs.existsSync(resolved)) {
    params.diagnostics.push({
      level: "error",
      message: `plugin path not found: ${resolved}`,
      source: resolved,
    });
    return;
  }

  const stat = fs.statSync(resolved);
  if (stat.isFile()) {
    if (!isExtensionFile(resolved)) {
      params.diagnostics.push({
        level: "error",
        message: `plugin path is not a supported file: ${resolved}`,
        source: resolved,
      });
      return;
    }
    addCandidate({
      candidates: params.candidates,
      seen: params.seen,
      idHint: path.basename(resolved, path.extname(resolved)),
      source: resolved,
      rootDir: path.dirname(resolved),
      origin: params.origin,
      workspaceDir: params.workspaceDir,
    });
    return;
  }

  if (stat.isDirectory()) {
    const manifest = readPackageManifest(resolved);
    const extensions = manifest ? resolvePackageExtensions(manifest) : [];

    if (extensions.length > 0) {
      for (const extPath of extensions) {
        const source = path.resolve(resolved, extPath);
        // Enforce path containment: extension entry must stay inside package directory
        if (!isPathInside(resolved, source)) {
          params.diagnostics.push({
            level: "error",
            message: `openclaw.extensions entry escapes package directory: ${extPath}`,
            source: resolved,
          });
          continue;
        }
        if (!isPathInsideWithRealpath(resolved, source, { requireRealpath: true })) {
          params.diagnostics.push({
            level: "error",
            message: `openclaw.extensions entry resolves outside package directory: ${extPath}`,
            source: resolved,
          });
          continue;
        }
        addCandidate({
          candidates: params.candidates,
          seen: params.seen,
          idHint: deriveIdHint({
            filePath: source,
            packageName: manifest?.name,
            hasMultipleExtensions: extensions.length > 1,
          }),
          source,
          rootDir: resolved,
          origin: params.origin,
          workspaceDir: params.workspaceDir,
          manifest,
          packageDir: resolved,
        });
      }
      return;
    }

    const indexCandidates = ["index.ts", "index.js", "index.mjs", "index.cjs"];
    const indexFile = indexCandidates
      .map((candidate) => path.join(resolved, candidate))
      .find((candidate) => fs.existsSync(candidate));

    if (indexFile && isExtensionFile(indexFile)) {
      addCandidate({
        candidates: params.candidates,
        seen: params.seen,
        idHint: path.basename(resolved),
        source: indexFile,
        rootDir: resolved,
        origin: params.origin,
        workspaceDir: params.workspaceDir,
        manifest,
        packageDir: resolved,
      });
      return;
    }

    discoverInDirectory({
      dir: resolved,
      origin: params.origin,
      workspaceDir: params.workspaceDir,
      candidates: params.candidates,
      diagnostics: params.diagnostics,
      seen: params.seen,
    });
    return;
  }
}

export function discoverClawdbotPlugins(params: {
  workspaceDir?: string;
  extraPaths?: string[];
}): PluginDiscoveryResult {
  const candidates: PluginCandidate[] = [];
  const diagnostics: PluginDiagnostic[] = [];
  const seen = new Set<string>();
  const workspaceDir = params.workspaceDir?.trim();

  const extra = params.extraPaths ?? [];
  for (const extraPath of extra) {
    if (typeof extraPath !== "string") continue;
    const trimmed = extraPath.trim();
    if (!trimmed) continue;
    discoverFromPath({
      rawPath: trimmed,
      origin: "config",
      workspaceDir: workspaceDir?.trim() || undefined,
      candidates,
      diagnostics,
      seen,
    });
  }
  if (workspaceDir) {
    const workspaceRoot = resolveUserPath(workspaceDir);
    const workspaceExt = path.join(workspaceRoot, ".openclaw", "extensions");
    discoverInDirectory({
      dir: workspaceExt,
      origin: "workspace",
      workspaceDir: workspaceRoot,
      candidates,
      diagnostics,
      seen,
    });
  }

  const globalDir = path.join(resolveConfigDir(), "extensions");
  discoverInDirectory({
    dir: globalDir,
    origin: "global",
    candidates,
    diagnostics,
    seen,
  });

  const bundledDir = resolveBundledPluginsDir();
  if (bundledDir) {
    discoverInDirectory({
      dir: bundledDir,
      origin: "bundled",
      candidates,
      diagnostics,
      seen,
    });
  }

  return { candidates, diagnostics };
}
