import { existsSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { PLATFORM_DIR } from '../data-path';
import { manifestProblems, type DiscoveredPlugin, type PluginManifest } from './manifest';

// Finding plugins on disk.
//
// They live at `<platform>/plugins/<app-name>/` — INSIDE the repository, not beside it, and that is what
// makes the whole developer story work. Bun links the workspace packages into the root `node_modules`, so
// anything under the repo can `import { useClient } from 'hooks/useClient'` with no publishing, no package
// registry and no version negotiation. A plugin author clones the platform, drops their plugin in, and
// runs it in dev — the WordPress model — and the same tree is what the server builds from.
//
// Verified: `Bun.resolveSync('hooks/useClient', '<platform>/plugins/anything')` resolves.
//
// Discovery is by CONVENTION. Presence is the declaration:
//
//   manifest.ts        required — everything a directory listing cannot say
//   api/router.ts      a backend router
//   db/schema.ts       tables
//   sidecar/index.ts   a process (`.mjs` instead means node — see below)
//   web/panels.ts      panel apps — REQUIRED with web/
//   web/layout.ts      how they are arranged — REQUIRED with web/
//
// Nothing here reads the database. This answers "what is on disk", which is a different question from
// "what is installed" — the install table answers that, and the two disagreeing is a state the app store
// has to render rather than a bug to prevent.

/** Where plugins live. Inside the repo, so the workspace packages resolve. */
export const PLUGINS_DIR = join(PLATFORM_DIR, 'plugins');

/**
 * The runtime is the file extension, not a manifest field.
 *
 * `sidecar/index.mjs` runs under node, `sidecar/index.ts` under bun. Implicit, but it is the rule this
 * repository already follows — `officer-pty` is `pty/index.mjs` under node because node-pty is a native
 * module built against Node's ABI, and everything else is bun. Better than a field that can contradict
 * the file it describes.
 */
function findSidecar(dir: string): DiscoveredPlugin['sidecar'] {
  const ts = join(dir, 'sidecar', 'index.ts');
  if (existsSync(ts)) return { script: ts, runtime: 'bun' };
  const mjs = join(dir, 'sidecar', 'index.mjs');
  if (existsSync(mjs)) return { script: mjs, runtime: 'node' };
  return null;
}

/**
 * A plugin's frontend, or a reason it is refused.
 *
 * `web/` present means BOTH `panels.ts` and `layout.ts` must be. That is how "every route renders a
 * workspace with at least one panel" is enforced: there is no way to ship a frontend that is not a
 * workspace, because the plugin never renders the screen — it hands over panels and an arrangement.
 */
function findWeb(dir: string, appName: string): { web: DiscoveredPlugin['web']; error?: string } {
  const webDir = join(dir, 'web');
  if (!existsSync(webDir)) return { web: null };

  const panels = join(webDir, 'panels.ts');
  const layout = join(webDir, 'layout.ts');
  const missing = [!existsSync(panels) && 'web/panels.ts', !existsSync(layout) && 'web/layout.ts'].filter(Boolean);
  if (missing.length) {
    return {
      web: null,
      error:
        `${appName}: has a web/ directory but is missing ${missing.join(' and ')}. ` +
        `Every plugin route renders a Workspace: contribute panels and a layout, not a screen.`,
    };
  }
  return { web: { panels, layout } };
}

const fileOrNull = (path: string): string | null => (existsSync(path) ? path : null);

/**
 * Read one plugin directory.
 *
 * Throws with every problem at once rather than the first, because a manifest fixed one field per attempt
 * is a manifest nobody finishes.
 */
export async function loadPlugin(dir: string, appName: string): Promise<DiscoveredPlugin> {
  const manifestPath = join(dir, 'manifest.ts');
  if (!existsSync(manifestPath)) throw new Error(`${appName}: no manifest.ts`);

  let manifest: PluginManifest | null = null;
  try {
    const module = (await import(manifestPath)) as { manifest?: PluginManifest };
    manifest = module.manifest ?? null;
  } catch (err) {
    throw new Error(`${appName}: manifest.ts failed to load — ${err instanceof Error ? err.message : String(err)}`);
  }

  const problems = manifestProblems(appName, manifest);
  if (problems.length) throw new Error(`${appName}: ${problems.join('; ')}`);

  const web = findWeb(dir, appName);
  if (web.error) throw new Error(web.error);

  return {
    appName,
    dir,
    manifest: manifest as PluginManifest,
    api: fileOrNull(join(dir, 'api', 'router.ts')),
    schema: fileOrNull(join(dir, 'db', 'schema.ts')),
    sidecar: findSidecar(dir),
    web: web.web,
  };
}

export type DiscoveryResult = {
  plugins: DiscoveredPlugin[];
  /** Directories that look like plugins but could not be read. Reported, never thrown — see below. */
  broken: { appName: string; error: string }[];
};

/**
 * Every plugin directory under `PLUGINS_DIR`.
 *
 * A broken plugin is COLLECTED, not thrown. One unreadable manifest must not stop the platform from
 * booting or hide the nine plugins beside it that are fine — and "this one is broken, here is why" is
 * something the app store can render, where a failed boot is something only a log can.
 */
export async function discoverPlugins(root: string = PLUGINS_DIR): Promise<DiscoveryResult> {
  if (!existsSync(root)) return { plugins: [], broken: [] };

  const plugins: DiscoveredPlugin[] = [];
  const broken: { appName: string; error: string }[] = [];

  for (const entry of readdirSync(root)) {
    // `_`-prefixed directories are scratch space, and dotfiles are not plugins.
    if (entry.startsWith('.') || entry.startsWith('_')) continue;
    const dir = join(root, entry);
    try {
      if (!statSync(dir).isDirectory()) continue;
    } catch {
      continue;
    }
    try {
      plugins.push(await loadPlugin(dir, entry));
    } catch (err) {
      broken.push({ appName: entry, error: err instanceof Error ? err.message : String(err) });
    }
  }

  return { plugins: plugins.sort((a, b) => a.appName.localeCompare(b.appName)), broken };
}
