import { existsSync } from "node:fs";
import { readdir, readFile, realpath, stat } from "node:fs/promises";
import { dirname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
import { loadPiWebConfig, piWebDataDir, type PiWebConfig } from "../config.js";
import type { PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope } from "../shared/apiTypes.js";
import { isPiWebPluginId } from "../shared/pluginIds.js";

export type { PiWebPluginInfo, PiWebPluginsResponse, PiWebPluginScope } from "../shared/apiTypes.js";

export interface PiWebPluginManifest {
  plugins: PiWebPluginManifestEntry[];
}

export interface PiWebPluginManifestEntry {
  id: string;
  module: string;
  source: string;
  scope: PiWebPluginScope;
  machineSpecific: boolean;
}

export interface ConfiguredPiPackage {
  source: string;
  scope: "user" | "project";
  installedPath?: string;
}

export interface PiPackageProvider {
  listPackages(): ConfiguredPiPackage[];
  getInstalledPath(source: string, scope: "user" | "project"): string | undefined;
}

interface PluginRecord {
  id: string;
  root: string;
  entryFile: string;
  version: string;
  source: string;
  scope: PiWebPluginScope;
  machineSpecific: boolean;
}

interface PiWebPluginServiceOptions {
  roots?: LocalPluginRoot[];
  cwd?: string;
  agentDir?: string;
  packageProvider?: PiPackageProvider | false;
  configProvider?: () => PiWebConfig;
}

interface LocalPluginRoot {
  path: string;
  source: string;
  scope: PiWebPluginScope;
}

interface PiWebPackageConfig {
  plugins: PiWebPluginEntry[];
}

interface PiWebPluginEntry {
  id: string;
  module: string;
  machineSpecific: boolean;
}

type ArraylessPluginRecord = Omit<PluginRecord, "source" | "scope">;

export class DefaultPiPackageProvider implements PiPackageProvider {
  private readonly packageManager: DefaultPackageManager;

  constructor(cwd = process.cwd(), agentDir = getAgentDir()) {
    this.packageManager = new DefaultPackageManager({
      cwd,
      agentDir,
      settingsManager: SettingsManager.create(cwd, agentDir),
    });
  }

  listPackages(): ConfiguredPiPackage[] {
    return this.packageManager.listConfiguredPackages();
  }

  getInstalledPath(source: string, scope: "user" | "project"): string | undefined {
    return this.packageManager.getInstalledPath(source, scope);
  }
}

export class PiWebPluginService {
  private readonly roots: LocalPluginRoot[];
  private readonly packageProvider: PiPackageProvider | undefined;
  private readonly configProvider: () => PiWebConfig;

  constructor(options: PiWebPluginServiceOptions = {}) {
    const cwd = options.cwd ?? process.cwd();
    const agentDir = options.agentDir ?? getAgentDir();
    this.roots = options.roots ?? defaultPluginRoots(cwd);
    this.packageProvider = options.packageProvider === false ? undefined : options.packageProvider ?? new DefaultPiPackageProvider(cwd, agentDir);
    this.configProvider = options.configProvider ?? (() => loadPiWebConfig({ cwd }).config);
  }

  async manifest(): Promise<PiWebPluginManifest> {
    return {
      plugins: (await this.plugins()).plugins
        .filter((plugin) => plugin.enabled)
        .map((plugin) => ({ id: plugin.id, module: plugin.module, source: plugin.source, scope: plugin.scope, machineSpecific: plugin.machineSpecific })),
    };
  }

  async plugins(): Promise<PiWebPluginsResponse> {
    const [plugins, config] = await Promise.all([this.discoverPlugins(), Promise.resolve(this.configProvider())]);
    return { plugins: plugins.map((plugin) => this.pluginInfo(plugin, config)) };
  }

  async readAsset(pluginId: string, assetPath: string): Promise<{ content: Buffer; contentType: string } | undefined> {
    if (!isPiWebPluginId(pluginId)) return undefined;
    const plugin = (await this.discoverPlugins()).find((candidate) => candidate.id === pluginId);
    if (plugin === undefined) return undefined;

    const resolved = resolve(plugin.root, assetPath);
    const [realRoot, realAsset] = await Promise.all([
      realpath(plugin.root),
      realpath(resolved).catch(() => undefined),
    ]);
    if (realAsset === undefined || !isWithin(realRoot, realAsset)) return undefined;

    const assetStat = await stat(realAsset).catch(() => undefined);
    if (assetStat?.isFile() !== true) return undefined;

    return { content: await readFile(realAsset), contentType: contentTypeFor(realAsset) };
  }

  private pluginInfo(plugin: PluginRecord, config: PiWebConfig): PiWebPluginInfo {
    return {
      id: plugin.id,
      module: `/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?v=${encodeURIComponent(plugin.version)}`,
      source: plugin.source,
      scope: plugin.scope,
      machineSpecific: plugin.machineSpecific,
      enabled: config.plugins?.[plugin.id]?.enabled !== false,
    };
  }

  private async discoverPlugins(): Promise<PluginRecord[]> {
    const records = new Map<string, PluginRecord>();
    for (const plugin of await this.discoverLocalPlugins()) addUnique(records, plugin);
    if (this.packageProvider !== undefined) {
      for (const plugin of await this.discoverPiPackagePlugins(this.packageProvider)) addUnique(records, plugin);
    }
    return [...records.values()].sort((left, right) => left.id.localeCompare(right.id));
  }

  private async discoverLocalPlugins(): Promise<PluginRecord[]> {
    const plugins: PluginRecord[] = [];
    for (const root of this.roots) plugins.push(...await discoverLocalRoot(root));
    return plugins;
  }

  private async discoverPiPackagePlugins(packageProvider: PiPackageProvider): Promise<PluginRecord[]> {
    const plugins: PluginRecord[] = [];
    for (const configuredPackage of packageProvider.listPackages()) {
      const root = configuredPackage.installedPath ?? packageProvider.getInstalledPath(configuredPackage.source, configuredPackage.scope);
      if (root === undefined) continue;
      try {
        plugins.push(...await discoverPackageRoot(root, configuredPackage));
      } catch (error) {
        warnInvalidPlugin(configuredPackage.source, error);
      }
    }
    return plugins;
  }
}

function defaultPluginRoots(cwd: string): LocalPluginRoot[] {
  const moduleDir = dirname(fileURLToPath(import.meta.url));
  const packageRoot = join(moduleDir, "..", "..");
  return [
    { path: bundledPluginRoot(packageRoot), source: "bundled", scope: "bundled" },
    ...sourceCheckoutPluginRoots(cwd),
    { path: join(piWebDataDir(), "plugins"), source: "local", scope: "local" },
  ];
}

function bundledPluginRoot(packageRoot: string): string {
  return join(packageRoot, "dist", "pi-web-plugins");
}

function sourceCheckoutPluginRoots(cwd: string): LocalPluginRoot[] {
  const pluginsRoot = join(cwd, "plugins");
  if (!existsSync(join(cwd, "src", "server", "index.ts")) || !existsSync(pluginsRoot)) return [];
  return [{ path: pluginsRoot, source: "dev", scope: "local" }];
}

async function discoverLocalRoot(root: LocalPluginRoot): Promise<PluginRecord[]> {
  if (!existsSync(root.path)) return [];
  const entries = await readdir(root.path, { withFileTypes: true }).catch(() => []);
  const plugins: PluginRecord[] = [];
  for (const entry of entries) {
    if (!isPiWebPluginId(entry.name)) continue;
    const pluginRoot = join(root.path, entry.name);
    const pluginStat = entry.isDirectory() ? undefined : entry.isSymbolicLink() ? await stat(pluginRoot).catch(() => undefined) : undefined;
    if (!entry.isDirectory() && pluginStat?.isDirectory() !== true) continue;
    try {
      plugins.push(...await discoverLocalPlugin(pluginRoot, root));
    } catch (error) {
      warnInvalidPlugin(pluginRoot, error);
    }
  }
  return plugins;
}

async function discoverLocalPlugin(root: string, localRoot: LocalPluginRoot): Promise<PluginRecord[]> {
  const config = await readPiWebPackageConfig(root);
  if (config === undefined) return [];
  const plugins = await discoverPluginEntries(root, config);
  return plugins.map((plugin) => ({ ...plugin, source: localRoot.source, scope: localRoot.scope }));
}

async function discoverPackageRoot(root: string, configuredPackage: ConfiguredPiPackage): Promise<PluginRecord[]> {
  const config = await readPiWebPackageConfig(root);
  if (config === undefined) return [];
  const plugins = await discoverPluginEntries(root, config);
  return plugins.map((plugin) => ({ ...plugin, source: configuredPackage.source, scope: configuredPackage.scope }));
}

async function discoverPluginEntries(root: string, config: PiWebPackageConfig): Promise<ArraylessPluginRecord[]> {
  const plugins: ArraylessPluginRecord[] = [];
  for (const entry of config.plugins) {
    if (!isSafeRelativePath(entry.module)) throw new Error(`Unsafe PI WEB plugin module path for ${entry.id}: ${entry.module}`);
    const entryPath = join(root, entry.module);
    const entryStat = await stat(entryPath).catch(() => undefined);
    if (entryStat?.isFile() !== true) throw new Error(`PI WEB plugin module not found for ${entry.id}: ${entry.module}`);
    plugins.push({ id: entry.id, root, entryFile: entry.module, version: String(Math.floor(entryStat.mtimeMs)), machineSpecific: entry.machineSpecific });
  }
  return plugins;
}

async function readPiWebPackageConfig(root: string): Promise<PiWebPackageConfig | undefined> {
  const packagePath = join(root, "package.json");
  const content = await readFile(packagePath, "utf8").catch(() => undefined);
  if (content === undefined) return undefined;
  const parsed: unknown = JSON.parse(content);
  if (!isRecord(parsed)) return undefined;
  const piWeb = parsed["piWeb"];
  if (!isRecord(piWeb)) return undefined;

  const plugins = parsePluginEntries(piWeb, packagePath);
  if (plugins.length === 0) return undefined;
  return { plugins };
}

function parsePluginEntries(piWeb: Record<string, unknown>, packagePath: string): PiWebPluginEntry[] {
  if (piWeb["plugin"] !== undefined) throw new Error(`Unsupported PI WEB plugin metadata in ${packagePath}: use piWeb.plugins with { id, module, machineSpecific? } entries`);
  const plugins = piWeb["plugins"];
  if (plugins === undefined) return [];
  if (!Array.isArray(plugins)) throw new Error(`PI WEB plugins must be an array in ${packagePath}`);

  return plugins.map((entry, index): PiWebPluginEntry => {
    if (!isRecord(entry)) throw new Error(`PI WEB plugin entry ${String(index + 1)} must be an object in ${packagePath}`);
    const id = entry["id"];
    const module = entry["module"];
    if (typeof id !== "string" || !isPiWebPluginId(id)) throw new Error(`Invalid PI WEB plugin id in ${packagePath}: ${String(id)}`);
    if (typeof module !== "string" || module === "") throw new Error(`Invalid PI WEB plugin module for ${id} in ${packagePath}`);
    return { id, module, machineSpecific: parseMachineSpecific(entry["machineSpecific"], packagePath, id) };
  });
}

function parseMachineSpecific(value: unknown, packagePath: string, pluginId: string): boolean {
  if (value === undefined) return false;
  if (typeof value !== "boolean") throw new Error(`Invalid PI WEB plugin machineSpecific value for ${pluginId} in ${packagePath}: ${formatUnknownValue(value)}`);
  return value;
}

function formatUnknownValue(value: unknown): string {
  if (typeof value === "string") return value;
  if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint" || typeof value === "symbol" || typeof value === "function" || value === null || value === undefined) return String(value);
  try {
    return JSON.stringify(value);
  } catch {
    return Object.prototype.toString.call(value);
  }
}

function addUnique(records: Map<string, PluginRecord>, plugin: PluginRecord): void {
  if (records.has(plugin.id)) {
    warnInvalidPlugin(plugin.source, `Duplicate PI WEB plugin id: ${plugin.id}`);
    return;
  }
  records.set(plugin.id, plugin);
}

function warnInvalidPlugin(source: string, error: unknown): void {
  const message = error instanceof Error ? error.message : String(error);
  console.warn(`Skipping PI WEB plugin from ${source}: ${message}`);
}

function isSafeRelativePath(path: string): boolean {
  return path !== "" && !path.includes("..") && !path.startsWith("/");
}

function isWithin(root: string, candidate: string): boolean {
  const rel = relative(root, candidate);
  return rel === "" || (!rel.startsWith("..") && !rel.startsWith(sep));
}

function contentTypeFor(path: string): string {
  if (path.endsWith(".js")) return "application/javascript; charset=utf-8";
  if (path.endsWith(".json")) return "application/json; charset=utf-8";
  if (path.endsWith(".css")) return "text/css; charset=utf-8";
  if (path.endsWith(".html")) return "text/html; charset=utf-8";
  return "application/octet-stream";
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}
