/**
 * Tier 2 e2e: spawn REAL openclaw, install our plugin from a built bundle of
 * `src/openclaw-context-engine.ts`, and assert the OpenClaw runtime actually
 * loads it, registers our default-export metadata, accepts it as the
 * `contextEngine` slot, and runs `plugins doctor` with zero error-level
 * diagnostics for our plugin id.
 *
 * Why this exists:
 *   The unit/e2e tests in test/context-engine.test.ts and
 *   test/e2e/openclaw-context-engine-plugin.test.ts both run STANDALONE —
 *   they mock the OpenClaw SDK or call our engine factory directly. Codex
 *   outside-voice F1 flagged that nothing in the repo proves OpenClaw's
 *   actual plugin loader walks our entry file, calls register(api), and
 *   accepts the registration. This test closes that gap.
 *
 * What this exercises end-to-end:
 *   1. `bun build` our entry → JS bundle (the same build the release would
 *      ship to ClawHub).
 *   2. `openclaw plugins install --link` against an isolated `--profile`
 *      directory.
 *   3. `openclaw plugins inspect <id> --json` reads our default-export shape
 *      back from the runtime registry (`status: 'loaded'`, `imported: true`,
 *      id/name/description match).
 *   4. `openclaw config set plugins.slots.contextEngine gbrain-context` →
 *      `openclaw config validate` confirms the slot binding is accepted.
 *   5. `openclaw plugins doctor` surfaces zero error-level diagnostics for
 *      our id.
 *   6. Public-SDK round-trip: import `registerContextEngine` from
 *      `openclaw/plugin-sdk` and register our factory directly, exercising
 *      the same registry our entry's register() hits.
 *
 * Skips gracefully when `openclaw` CLI is unavailable (Tier 2 like
 * test/e2e/skills.test.ts). Uses an isolated `--profile` so the user's real
 * openclaw state is untouched; cleans up the profile + plugin install in
 * afterAll regardless of test outcome.
 */

import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
import { spawnSync } from 'child_process';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync, realpathSync } from 'fs';
import { join, dirname } from 'path';
import { tmpdir, homedir } from 'os';

// ── Tier 2 gating ──────────────────────────────────────────────────────────
const OPENCLAW = which('openclaw');
const SKIP = !OPENCLAW;
const SKIP_MSG = '[openclaw-plugin-load-real] openclaw CLI not available; skipping Tier 2 e2e';

function which(bin: string): string | null {
  const r = spawnSync('which', [bin], { encoding: 'utf8' });
  if (r.status !== 0) return null;
  const path = r.stdout.trim();
  return path || null;
}

// Hardcoded plugin id matches src/openclaw-context-engine.ts default export.
const PLUGIN_ID = 'gbrain-context-engine';
const ENGINE_ID = 'gbrain-context';
// Use a process-unique profile name so two concurrent test runs (e.g.,
// Conductor sibling workspaces) don't collide on `~/.openclaw-<profile>`.
const PROFILE = `gbrain-ctx-e2e-${process.pid}`;
const PROFILE_DIR = join(homedir(), `.openclaw-${PROFILE}`);

let fixtureDir = '';
let repoRoot = '';

function runOpenclaw(args: string[], opts: { timeoutMs?: number } = {}): {
  exitCode: number;
  stdout: string;
  stderr: string;
} {
  const r = spawnSync(OPENCLAW!, ['--profile', PROFILE, ...args], {
    encoding: 'utf8',
    timeout: opts.timeoutMs ?? 60_000,
    env: { ...process.env },
  });
  return {
    exitCode: r.status ?? -1,
    stdout: r.stdout ?? '',
    stderr: r.stderr ?? '',
  };
}

function cleanup() {
  if (!OPENCLAW) return;
  // Best-effort: uninstall the plugin and rm the profile dir. Both may
  // already be gone (e.g., beforeAll partial failure); ignore errors.
  try {
    runOpenclaw(['plugins', 'uninstall', '--force', PLUGIN_ID], { timeoutMs: 30_000 });
  } catch { /* noop */ }
  try {
    if (existsSync(PROFILE_DIR)) rmSync(PROFILE_DIR, { recursive: true, force: true });
  } catch { /* noop */ }
  try {
    if (fixtureDir && existsSync(fixtureDir)) rmSync(fixtureDir, { recursive: true, force: true });
  } catch { /* noop */ }
}

describe('openclaw-plugin-load-real (Tier 2 e2e)', () => {
  beforeAll(async () => {
    if (SKIP) {
      console.warn(SKIP_MSG);
      return;
    }

    repoRoot = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' })
      .stdout.trim();
    if (!repoRoot) throw new Error('not in a git repo — cannot resolve plugin source path');

    fixtureDir = mkdtempSync(join(tmpdir(), 'gbrain-ctx-plugin-real-'));

    // Write the fixture's package.json + openclaw.plugin.json from templates.
    const fixtureTemplate = join(repoRoot, 'test', 'fixtures', 'openclaw-plugin-real');
    writeFileSync(
      join(fixtureDir, 'package.json'),
      readFileSync(join(fixtureTemplate, 'package.json.template'), 'utf8'),
    );
    writeFileSync(
      join(fixtureDir, 'openclaw.plugin.json'),
      readFileSync(join(fixtureTemplate, 'openclaw.plugin.json.template'), 'utf8'),
    );

    // Build our real entry to a single JS bundle. This is the same source
    // (`src/openclaw-context-engine.ts`) that the release ships; only the
    // packaging layer (test fixture's package.json) is test-specific.
    const buildResult = spawnSync(
      'bun',
      [
        'build',
        join(repoRoot, 'src', 'openclaw-context-engine.ts'),
        '--target=bun',
        '--outfile',
        join(fixtureDir, 'entry.js'),
      ],
      { encoding: 'utf8', timeout: 60_000 },
    );
    if (buildResult.status !== 0) {
      throw new Error(`bun build failed (exit ${buildResult.status}): ${buildResult.stderr}`);
    }
    if (!existsSync(join(fixtureDir, 'entry.js'))) {
      throw new Error('bun build did not produce entry.js');
    }

    // Install via openclaw plugins install --link into the isolated profile.
    // `--dangerously-force-unsafe-install` is required because openclaw's
    // dangerous-code scanner flags the surrounding gbrain repo (tests use
    // child_process etc.); the test fixture is dev-trusted, same provenance
    // as the test runner.
    const install = runOpenclaw([
      'plugins', 'install',
      '--link',
      '--dangerously-force-unsafe-install',
      fixtureDir,
    ], { timeoutMs: 60_000 });
    if (install.exitCode !== 0) {
      throw new Error(
        `openclaw plugins install failed (exit ${install.exitCode}).\n` +
        `stdout: ${install.stdout}\nstderr: ${install.stderr}`,
      );
    }
  });

  afterAll(() => {
    cleanup();
  });

  it.skipIf(SKIP)(
    'openclaw imports the entry file and reports status=loaded',
    () => {
      const r = runOpenclaw(['plugins', 'inspect', PLUGIN_ID, '--json'], { timeoutMs: 30_000 });
      expect(r.exitCode).toBe(0);

      const inspect = JSON.parse(r.stdout);
      expect(inspect.plugin).toBeDefined();
      // status=loaded means: openclaw imported the entry.js module, read the
      // default export, and called register(api) without throwing.
      expect(inspect.plugin.status).toBe('loaded');
      expect(inspect.plugin.imported).toBe(true);
      expect(inspect.plugin.activated).toBe(true);
    },
  );

  it.skipIf(SKIP)(
    'default export carries the expected id / name / description metadata',
    () => {
      const r = runOpenclaw(['plugins', 'inspect', PLUGIN_ID, '--json'], { timeoutMs: 30_000 });
      expect(r.exitCode).toBe(0);
      const inspect = JSON.parse(r.stdout);

      // Openclaw reads these directly from the default export of our entry.
      // If we rename a field in src/openclaw-context-engine.ts, this fails.
      expect(inspect.plugin.id).toBe(PLUGIN_ID);
      expect(inspect.plugin.name).toBe('GBrain Context Engine');
      expect(inspect.plugin.description).toContain('Deterministic temporal/spatial context injection');
    },
  );

  it.skipIf(SKIP)(
    'register(api) ran without producing error-level diagnostics',
    () => {
      const r = runOpenclaw(['plugins', 'inspect', PLUGIN_ID, '--json'], { timeoutMs: 30_000 });
      expect(r.exitCode).toBe(0);
      const inspect = JSON.parse(r.stdout);

      const errors = (inspect.diagnostics ?? []).filter((d: { level: string }) => d.level === 'error');
      expect(errors).toEqual([]);

      // The trust warning is expected for --link installs — it's openclaw
      // telling the operator that --link bypasses install-record provenance.
      // We assert it's there so a future openclaw change that elevates it to
      // error-level surfaces here too.
      const warns = (inspect.diagnostics ?? []).filter((d: { level: string }) => d.level === 'warn');
      const hasTrustWarning = warns.some((d: { message: string }) =>
        d.message.includes('install/load-path provenance'),
      );
      expect(hasTrustWarning).toBe(true);
    },
  );

  it.skipIf(SKIP)(
    'plugins.slots.contextEngine binding to gbrain-context validates cleanly',
    () => {
      // Wiring our id into the slot is the runtime hand-off — when
      // openclaw initializes an agent, it reads this slot and resolves the
      // engine from the contextEngine registry. config validate fails if
      // the slot value doesn't reference a known engine.
      const setResult = runOpenclaw(
        ['config', 'set', 'plugins.slots.contextEngine', ENGINE_ID],
        { timeoutMs: 30_000 },
      );
      expect(setResult.exitCode).toBe(0);

      const validateResult = runOpenclaw(['config', 'validate'], { timeoutMs: 30_000 });
      expect(validateResult.exitCode).toBe(0);
      expect(validateResult.stdout).toContain('Config valid');
    },
  );

  it.skipIf(SKIP)(
    'plugins doctor produces zero errors for our plugin id',
    () => {
      const r = runOpenclaw(['plugins', 'doctor'], { timeoutMs: 30_000 });
      // Doctor may exit non-zero overall if ANY plugin has issues; the
      // assertion we care about is that OUR id is not in an error line.
      // Match a "${PLUGIN_ID}: <message>" pattern with error keywords.
      const combined = `${r.stdout}\n${r.stderr}`;
      const lines = combined.split('\n').filter(l => l.includes(PLUGIN_ID));
      const errorLines = lines.filter(l =>
        /error|failed|cannot|missing|not registered/i.test(l)
        && !l.includes('without install/load-path provenance'), // expected trust warning
      );
      if (errorLines.length > 0) {
        console.error('Unexpected error lines for', PLUGIN_ID, ':\n', errorLines.join('\n'));
      }
      expect(errorLines).toEqual([]);
    },
  );

  it.skipIf(SKIP)(
    'openclaw public SDK registerContextEngine accepts our factory shape',
    async () => {
      // Programmatic round-trip via the SDK that plugin entries actually
      // use. This is the API our register(api) calls; importing and using
      // it directly proves our factory's call signature matches what
      // openclaw's runtime expects. If openclaw renames the export or
      // changes the factory contract, this test fails.
      // Try the bare specifier first (works if openclaw is installed in the
      // workspace's node_modules). Fall back to the global install location
      // discovered via `npm root -g` (common for users who installed
      // openclaw with `npm install -g openclaw`). The fallback uses an
      // absolute path so Bun's resolver doesn't need a registered
      // module-mapping. If both fail, fail loudly — this is the round-trip
      // test, silently skipping defeats its purpose now that the suite-
      // level fixture proved openclaw is installed and reachable.
      let registerContextEngine: ((id: string, factory: () => unknown) => void) | undefined;

      // Minimal structural shape of the openclaw plugin SDK surface this
      // test uses. `openclaw` is deliberately NOT a declared dependency —
      // the test probes whatever install is present at runtime — so the
      // import result is cast to this local interface instead of letting
      // TypeScript type-check against whichever openclaw version happens
      // to be resolvable from an ancestor node_modules. That keeps
      // `bunx tsc --noEmit` hermetic on a clean checkout (#2729); the
      // export's presence/shape is still verified at runtime below.
      interface OpenclawPluginSdk {
        registerContextEngine?: (id: string, factory: () => unknown) => void;
      }

      const importErrors: string[] = [];
      try {
        // @ts-ignore — bare specifier; whether this resolves (TS2307 or not)
        // depends on ambient node_modules, so @ts-expect-error would flip.
        const sdk = (await import('openclaw/plugin-sdk')) as unknown as OpenclawPluginSdk;
        registerContextEngine = sdk.registerContextEngine;
      } catch (err) {
        importErrors.push(`bare 'openclaw/plugin-sdk': ${err instanceof Error ? err.message : String(err)}`);
      }

      if (!registerContextEngine) {
        // Bare specifier failed — resolve via the installed openclaw binary
        // itself. `which openclaw` gave us a shim path; `realpathSync`
        // follows the symlink chain to the actual openclaw module directory
        // (e.g., /opt/homebrew/lib/node_modules/openclaw/openclaw.mjs).
        // From there, dist/plugin-sdk/index.js is the public SDK entry.
        // This works regardless of how openclaw was installed (Homebrew,
        // bare npm -g, nvm, asdf, volta) because it follows the actual
        // filesystem link.
        try {
          const realBin = realpathSync(OPENCLAW!);
          const openclawModuleDir = dirname(realBin);
          const sdkPath = join(openclawModuleDir, 'dist', 'plugin-sdk', 'index.js');
          if (existsSync(sdkPath)) {
            // @ts-ignore — absolute-path dynamic import bypasses node_modules resolution.
            const sdk = await import(sdkPath);
            registerContextEngine = sdk.registerContextEngine;
          } else {
            importErrors.push(`SDK not found at ${sdkPath} (resolved from openclaw bin ${realBin})`);
          }
        } catch (err) {
          importErrors.push(`bin-relative resolve: ${err instanceof Error ? err.message : String(err)}`);
        }
      }

      if (!registerContextEngine) {
        throw new Error(
          `openclaw/plugin-sdk could not be resolved from any known location. ` +
          `Errors:\n  ${importErrors.join('\n  ')}\n` +
          `This test runs only when openclaw CLI is installed; SDK resolution should follow.`,
        );
      }
      expect(typeof registerContextEngine).toBe('function');

      const { createGBrainContextEngine } = await import('../../src/core/context-engine.ts');

      const tmp = mkdtempSync(join(tmpdir(), 'gbrain-ctx-sdk-rt-'));
      try {
        mkdirSync(join(tmp, 'memory'), { recursive: true });
        writeFileSync(join(tmp, 'memory', 'heartbeat-state.json'), '{}');
        writeFileSync(join(tmp, 'memory', 'upcoming-flights.json'), '{}');

        // Use a process-unique id so we don't collide with whatever the
        // suite-level plugin installation already wrote into the registry.
        const dynamicId = `${ENGINE_ID}-sdk-rt-${process.pid}`;

        // Should not throw. If openclaw's API contract drifts (e.g., requires
        // additional args, returns Promise instead of void), this fails.
        registerContextEngine!(
          dynamicId,
          () => createGBrainContextEngine({ workspaceDir: tmp }),
        );

        // Also verify the engine the factory produces still has the
        // expected ContextEngine interface shape (info, ingest, assemble,
        // compact) — these are the methods openclaw will call.
        const engine = createGBrainContextEngine({ workspaceDir: tmp });
        expect(engine.info.id).toBe(ENGINE_ID);
        expect(typeof engine.ingest).toBe('function');
        expect(typeof engine.assemble).toBe('function');
        expect(typeof engine.compact).toBe('function');

        // Assemble actually produces the Live Context block — same code
        // path openclaw will hit when it resolves the slot during an agent
        // turn. Proves the FULL load-and-call path works against openclaw's
        // public SDK.
        const result = await engine.assemble({ sessionId: 'sdk-roundtrip', messages: [] });
        expect(result.systemPromptAddition).toContain('Live Context');
      } finally {
        rmSync(tmp, { recursive: true, force: true });
      }
    },
  );
});
