// ---------------------------------------------------------------------------
// loadPluginsFromJsonc — runtime plugin loader.
//
// Reads `executor.jsonc#plugins`, dynamically imports each package's
// `./server` entry via jiti (so workspace TS sources work in dev and
// published `dist/*.js` works after install), and calls the exported
// `definePlugin(...)` factory with merged `options` plus host-injected
// deps. Returns the resulting `Plugin[]` ready to hand to
// `composePluginApi` / `createExecutor`.
//
// jiti is used instead of bare `import()` because:
//   - workspace plugins under monorepo dev expose `.ts` source via the
//     `bun` export condition; Node's loader can't read those directly,
//     jiti transpiles on the fly.
//   - in a published environment the package's `default` condition
//     points at `dist/*.js`, which jiti loads as a normal ESM module.
//
// The convention is: every plugin package exports a `./server` subpath
// whose default export is a `ConfiguredPlugin` (the result of
// `definePlugin(...)`). Calling that with `{ ...options, ...deps }`
// returns a concrete `Plugin`.
// ---------------------------------------------------------------------------

import { createRequire } from "node:module";
import { dirname, isAbsolute, resolve as resolvePath } from "node:path";
import { pathToFileURL } from "node:url";
import * as fs from "node:fs";
import * as jsonc from "jsonc-parser";
import { Effect, Schema } from "effect";

import type { AnyPlugin } from "@executor-js/sdk";

// Plugins are invoked dynamically by name — exact author types are
// unknown at the call site, so the loader treats every factory as
// `(options?: unknown) => AnyPlugin`. The plugin author's types still
// hold inside the plugin's own module; we just don't propagate them
// across the runtime boundary.
type LooseConfiguredPlugin = (options?: Record<string, unknown>) => AnyPlugin;

import { ExecutorFileConfig } from "./schema";

export class LoadPluginsError extends Schema.TaggedErrorClass<LoadPluginsError>()(
  "LoadPluginsError",
  {
    message: Schema.String,
    cause: Schema.optional(Schema.Unknown),
  },
) {}

export interface LoadPluginsFromJsoncOptions {
  /** Absolute path to `executor.jsonc` (or compatible). */
  readonly path: string;
  /**
   * Host-injected deps merged into each plugin's options. Common keys:
   * `configFile` (the `ConfigFileSink`), env-derived credentials, etc.
   * Plugins ignore deps they don't accept — `definePlugin` strips
   * unknown keys before forwarding to the author factory.
   */
  readonly deps?: Readonly<Record<string, unknown>>;
}

/**
 * Returns the plugins listed in jsonc, or `null` if the file is missing
 * or has no `plugins` array. The host treats `null` as "fall back to
 * the static `executor.config.ts` factory."
 */
export const loadPluginsFromJsonc = async (
  options: LoadPluginsFromJsoncOptions,
): Promise<readonly AnyPlugin[] | null> => Effect.runPromise(loadPluginsFromJsoncEffect(options));

const loadPluginsFromJsoncEffect = (
  options: LoadPluginsFromJsoncOptions,
): Effect.Effect<readonly AnyPlugin[] | null, LoadPluginsError> =>
  Effect.gen(function* () {
    const { path, deps } = options;
    if (!fs.existsSync(path)) return null;

    const raw = fs.readFileSync(path, "utf8");
    const errors: jsonc.ParseError[] = [];
    const parsed = jsonc.parse(raw, errors);
    if (errors.length > 0) {
      const msg = errors
        .map((e) => `offset ${e.offset}: ${jsonc.printParseErrorCode(e.error)}`)
        .join("; ");
      return yield* new LoadPluginsError({
        message: `[load-plugins] failed to parse ${path}: ${msg}`,
      });
    }

    const config = yield* Schema.decodeUnknownEffect(ExecutorFileConfig)(parsed).pipe(
      Effect.mapError(
        (error) =>
          new LoadPluginsError({
            message: `[load-plugins] failed to decode ${path}: ${error.issue.toString()}`,
            cause: error,
          }),
      ),
    );

    const entries = config.plugins ?? null;
    if (!entries || entries.length === 0) return null;

    // jiti is created once per call; `moduleCache: false` ensures a
    // restart picks up freshly-installed packages without process restart
    // (relevant when the dev server kicks a reload after `executor plugin
    // install`).
    const { createJiti } = yield* Effect.tryPromise({
      try: () => import("jiti"),
      catch: (cause) =>
        new LoadPluginsError({
          message: `[load-plugins] failed to import jiti.`,
          cause,
        }),
    });
    const jiti = createJiti(pathToFileURL(path).href, {
      interopDefault: true,
      moduleCache: false,
    });

    const fromDir = dirname(path);
    // require.resolve is anchored to the jsonc's directory so plugin
    // packages resolve from the host app's `node_modules` regardless of
    // CWD.
    const require = createRequire(isAbsolute(path) ? path : resolvePath(fromDir, "_anchor.js"));

    const loaded: AnyPlugin[] = [];
    for (const entry of entries) {
      const serverEntry = `${entry.package}/server`;
      const resolved = yield* Effect.try({
        try: () => require.resolve(serverEntry),
        catch: (cause) =>
          new LoadPluginsError({
            message:
              `[load-plugins] cannot resolve "${serverEntry}" from ${fromDir}. ` +
              `Is "${entry.package}" installed and does it export "./server"?`,
            cause,
          }),
      });
      const mod = (yield* Effect.tryPromise({
        try: () => jiti.import(resolved),
        catch: (cause) =>
          new LoadPluginsError({
            message: `[load-plugins] failed to import "${serverEntry}" from ${resolved}.`,
            cause,
          }),
      })) as { default?: LooseConfiguredPlugin } | LooseConfiguredPlugin;
      const factory = (
        typeof mod === "function" ? mod : (mod.default ?? null)
      ) as LooseConfiguredPlugin | null;
      if (!factory || typeof factory !== "function") {
        return yield* new LoadPluginsError({
          message:
            `[load-plugins] "${serverEntry}" did not export a default ` +
            `definePlugin(...) factory.`,
        });
      }
      const merged = { ...(deps ?? {}), ...(entry.options ?? {}) };
      loaded.push(factory(merged));
    }

    return loaded;
  });
