import { describe, expect, it, vi } from 'vitest';
import {
  Container,
  DefaultLogger,
  DefaultPluginAPI,
  EventBus,
  HookRegistry,
  HookRunner,
  ProviderRegistry,
  ToolRegistry,
} from '../../src/index.js';
import type { Config, ProviderFactory, Tool } from '../../src/index.js';

const baseConfig: Config = {
  providers: {},
  log: { level: 'error' },
} as unknown as Config;

const tool = (name: string): Tool => ({
  name,
  description: '',
  inputSchema: { type: 'object' },
  permission: 'auto',
  mutating: false,
  async execute() {
    return '';
  },
});

function mkApi() {
  const container = new Container();
  const events = new EventBus();
  const pipelines = {} as Parameters<typeof DefaultPluginAPI>[0]['pipelines'];
  const toolRegistry = new ToolRegistry();
  const providerRegistry = new ProviderRegistry();
  const log = new DefaultLogger({ level: 'error' });
  const api = new DefaultPluginAPI({
    ownerName: 'plugin-x',
    container,
    events,
    pipelines,
    toolRegistry,
    providerRegistry,
    config: baseConfig,
    log,
  });
  return { api, toolRegistry, providerRegistry };
}

describe('DefaultPluginAPI.registerHook', () => {
  function mkApiWithHooks() {
    const hookRegistry = new HookRegistry();
    const api = new DefaultPluginAPI({
      ownerName: 'plugin-x',
      container: new Container(),
      events: new EventBus(),
      pipelines: {} as Parameters<typeof DefaultPluginAPI>[0]['pipelines'],
      toolRegistry: new ToolRegistry(),
      providerRegistry: new ProviderRegistry(),
      config: baseConfig,
      log: new DefaultLogger({ level: 'error' }),
      hookRegistry,
    });
    return { api, hookRegistry };
  }

  it('registers an in-process hook that the runner invokes', async () => {
    const { api, hookRegistry } = mkApiWithHooks();
    api.registerHook('PreToolUse', 'Bash', () => ({ decision: 'block', reason: 'nope' }));
    const runner = new HookRunner({ registry: hookRegistry });
    const r = await runner.preToolUse('bash', {}, { cwd: '/x' });
    expect(r.block).toBe(true);
  });

  it('drainCleanup removes registered hooks', async () => {
    const { api, hookRegistry } = mkApiWithHooks();
    api.registerHook('PreToolUse', '*', () => ({ decision: 'block' }));
    api.drainCleanup();
    const runner = new HookRunner({ registry: hookRegistry });
    expect(await runner.preToolUse('bash', {}, { cwd: '/x' })).toEqual({});
  });

  it('is a noop when no hookRegistry is wired', () => {
    const api = new DefaultPluginAPI({
      ownerName: 'plugin-x',
      container: new Container(),
      events: new EventBus(),
      pipelines: {} as Parameters<typeof DefaultPluginAPI>[0]['pipelines'],
      toolRegistry: new ToolRegistry(),
      providerRegistry: new ProviderRegistry(),
      config: baseConfig,
      log: new DefaultLogger({ level: 'error' }),
    });
    expect(() => api.registerHook('Stop', undefined, () => {})()).not.toThrow();
  });
});

describe('DefaultPluginAPI', () => {
  it('tools.register attributes ownership and list reflects it', () => {
    const { api, toolRegistry } = mkApi();
    api.tools.register(tool('alpha'));
    expect(api.tools.list().map((t) => t.name)).toContain('alpha');
    expect(toolRegistry.get('alpha')?.name).toBe('alpha');
  });

  it('tools.unregister removes the tool', () => {
    const { api } = mkApi();
    api.tools.register(tool('alpha'));
    api.tools.unregister('alpha');
    expect(api.tools.get('alpha')).toBeUndefined();
  });

  it('providers.register / list works', () => {
    const { api } = mkApi();
    const factory: ProviderFactory = {
      type: 'mock',
      family: 'openai-compatible',
      create: () => ({
        id: 'mock',
        capabilities: {} as never,
        complete: async () => ({
          content: [],
          stopReason: 'end_turn',
          usage: { input: 0, output: 0 },
          model: 'm',
        }),
      }),
    };
    api.providers.register(factory);
    expect(api.providers.list()).toContain('mock');
  });

  it('providers.create dispatches to registered factory', () => {
    const { api } = mkApi();
    const create = vi.fn().mockReturnValue({ id: 'mock' });
    api.providers.register({ type: 'mock', family: 'openai', create });
    api.providers.create({ type: 'mock', apiKey: 'k' });
    expect(create).toHaveBeenCalled();
  });

  it('mcp falls back to noop when not provided', async () => {
    const { api } = mkApi();
    await expect(api.mcp.start({ name: 'x' } as never)).resolves.toBeUndefined();
    await expect(api.mcp.stop('x')).resolves.toBeUndefined();
    await expect(api.mcp.restart('x')).resolves.toBeUndefined();
    expect(api.mcp.list()).toEqual([]);
  });

  it('uses provided mcpRegistry view when given', () => {
    const container = new Container();
    const events = new EventBus();
    const log = new DefaultLogger({ level: 'error' });
    const mcpList = vi.fn().mockReturnValue([{ name: 'srv', state: 'connected', toolCount: 1 }]);
    const api = new DefaultPluginAPI({
      ownerName: 'p',
      container,
      events,
      pipelines: {} as never,
      toolRegistry: new ToolRegistry(),
      providerRegistry: new ProviderRegistry(),
      config: baseConfig,
      log,
      mcpRegistry: {
        start: async () => undefined,
        stop: async () => undefined,
        restart: async () => undefined,
        list: mcpList,
      },
    });
    expect(api.mcp.list()).toEqual([{ name: 'srv', state: 'connected', toolCount: 1 }]);
    expect(mcpList).toHaveBeenCalled();
  });

  // ── events / lifecycle ─────────────────────────────────────────────────────

  it('onEvent attaches listener and returns an off function that unsubscribes', () => {
    const { api } = mkApi();
    const handler = vi.fn();
    const off = api.onEvent('tool.before' as never, handler);
    (api.events as never as { emit: (e: string, p: unknown) => void }).emit('tool.before', {
      x: 1,
    });
    expect(handler).toHaveBeenCalledTimes(1);
    off();
    (api.events as never as { emit: (e: string, p: unknown) => void }).emit('tool.before', {
      x: 2,
    });
    expect(handler).toHaveBeenCalledTimes(1);
  });

  it('onPattern matches by wildcard and returns an off function', () => {
    const { api } = mkApi();
    const handler = vi.fn();
    const off = api.onPattern('tool.*', handler);
    (api.events as never as { emit: (e: string, p: unknown) => void }).emit('tool.after', {
      ok: true,
    });
    expect(handler).toHaveBeenCalled();
    off();
  });

  it('emitCustom dispatches a custom (non-typed) event through the bus', () => {
    const { api } = mkApi();
    const handler = vi.fn();
    api.onPattern('custom.*', handler);
    api.emitCustom('custom.frobulate', { value: 42 });
    expect(handler).toHaveBeenCalledWith('custom.frobulate', { value: 42 });
  });

  it('drainCleanup invokes every collected off function once', () => {
    const { api } = mkApi();
    const a = vi.fn();
    const b = vi.fn();
    api.onEvent('tool.before' as never, a);
    api.onEvent('tool.after' as never, b);
    api.drainCleanup();
    // subsequent emits should not fire the original handlers because cleanup removed them
    (api.events as never as { emit: (e: string, p: unknown) => void }).emit('tool.before', {});
    (api.events as never as { emit: (e: string, p: unknown) => void }).emit('tool.after', {});
    expect(a).not.toHaveBeenCalled();
    expect(b).not.toHaveBeenCalled();
  });

  it('drainCleanup swallows errors thrown by cleanup functions', () => {
    const { api } = mkApi();
    // Inject a throwing cleanup via onEvent + monkey-patched off — use the cleanup
    // path directly by registering an extension that simulates one.
    // Easier: register two real listeners; replace the queued off function with a thrower.
    const fns = (api as unknown as { pluginCleanupFns: Array<() => void> }).pluginCleanupFns;
    fns.push(() => {
      throw new Error('boom');
    });
    fns.push(vi.fn());
    expect(() => api.drainCleanup()).not.toThrow();
    expect(fns.length).toBe(0);
  });

  // ── config / system prompt ─────────────────────────────────────────────────

  it('onConfigChange returns a noop when no configStore is provided', () => {
    const { api } = mkApi();
    const off = api.onConfigChange(() => {});
    expect(typeof off).toBe('function');
    expect(() => off()).not.toThrow();
  });

  it('onConfigChange forwards to configStore.watch when provided', () => {
    const watch = vi.fn().mockReturnValue(() => 'detached');
    const log = new DefaultLogger({ level: 'error' });
    const api = new DefaultPluginAPI({
      ownerName: 'p',
      container: new Container(),
      events: new EventBus(),
      pipelines: {} as never,
      toolRegistry: new ToolRegistry(),
      providerRegistry: new ProviderRegistry(),
      config: baseConfig,
      log,
      configStore: { watch },
    });
    const handler = vi.fn();
    api.onConfigChange(handler);
    expect(watch).toHaveBeenCalledWith(handler);
  });

  it('registerSystemPromptContributor delegates to the extension registry', () => {
    const { api } = mkApi();
    const contributor = { id: 'p:hello', contribute: () => 'hi' };
    const off = api.registerSystemPromptContributor(contributor as never);
    expect(typeof off).toBe('function');
    const contributors = api.extensions.listSystemPromptContributors();
    expect(contributors.some((c) => c.id === 'p:hello')).toBe(true);
    off();
    expect(api.extensions.listSystemPromptContributors().some((c) => c.id === 'p:hello')).toBe(
      false,
    );
  });

  // ── slash commands ─────────────────────────────────────────────────────────

  it('slashCommands view delegates register/unregister/get/list to the host registry', async () => {
    const { SlashCommandRegistry } = await import('../../src/index.js');
    const scr = new SlashCommandRegistry();
    const log = new DefaultLogger({ level: 'error' });
    const api = new DefaultPluginAPI({
      ownerName: 'p',
      container: new Container(),
      events: new EventBus(),
      pipelines: {} as never,
      toolRegistry: new ToolRegistry(),
      providerRegistry: new ProviderRegistry(),
      config: baseConfig,
      log,
      slashCommandRegistry: scr,
    });
    const cmd = { name: 'plugcmd', description: 'd', run: async () => ({}) };
    api.slashCommands.register(cmd);
    // Plugin-registered commands are namespaced as `<owner>:<name>`
    expect(api.slashCommands.get('p:plugcmd')?.name).toBe('plugcmd');
    expect(api.slashCommands.list().map((c) => c.name)).toContain('plugcmd');
    expect(api.slashCommands.unregister('p:plugcmd')).toBe(true);
    expect(api.slashCommands.get('p:plugcmd')).toBeUndefined();
  });

  it('slashCommands falls back to noop view when no host registry is provided', () => {
    const { api } = mkApi();
    expect(() =>
      api.slashCommands.register({ name: 'x', description: '', run: async () => ({}) }),
    ).not.toThrow();
    expect(api.slashCommands.unregister('x')).toBe(false);
    expect(api.slashCommands.get('x')).toBeUndefined();
    expect(api.slashCommands.list()).toEqual([]);
  });

  // ── metrics scoping ────────────────────────────────────────────────────────

  it('scopedMetrics prefixes every metric name with `plugin.<name>.`', () => {
    const sink = { counter: vi.fn(), histogram: vi.fn(), gauge: vi.fn() };
    const log = new DefaultLogger({ level: 'error' });
    const api = new DefaultPluginAPI({
      ownerName: 'cool-plugin',
      container: new Container(),
      events: new EventBus(),
      pipelines: {} as never,
      toolRegistry: new ToolRegistry(),
      providerRegistry: new ProviderRegistry(),
      config: baseConfig,
      log,
      metricsSink: sink,
    });
    api.metrics.counter('hits', 1, { a: 'b' });
    api.metrics.histogram('latency', 50);
    api.metrics.gauge('queue_depth', 3);
    expect(sink.counter).toHaveBeenCalledWith('plugin.cool-plugin.hits', 1, { a: 'b' });
    expect(sink.histogram).toHaveBeenCalledWith('plugin.cool-plugin.latency', 50, undefined);
    expect(sink.gauge).toHaveBeenCalledWith('plugin.cool-plugin.queue_depth', 3, undefined);
  });

  it('metrics falls back to a noop sink when none provided', () => {
    const { api } = mkApi();
    expect(() => {
      api.metrics.counter('x', 1);
      api.metrics.histogram('x', 1);
      api.metrics.gauge('x', 1);
    }).not.toThrow();
  });

  // ── session writer ─────────────────────────────────────────────────────────

  it('session uses provided writer when given', async () => {
    const append = vi.fn().mockResolvedValue(undefined);
    const log = new DefaultLogger({ level: 'error' });
    const api = new DefaultPluginAPI({
      ownerName: 'p',
      container: new Container(),
      events: new EventBus(),
      pipelines: {} as never,
      toolRegistry: new ToolRegistry(),
      providerRegistry: new ProviderRegistry(),
      config: baseConfig,
      log,
      sessionWriter: { append },
    });
    await api.session.append({ type: 'event' } as never);
    expect(append).toHaveBeenCalled();
  });

  it('session falls back to a noop writer otherwise', async () => {
    const { api } = mkApi();
    await expect(api.session.append({ type: 'event' } as never)).resolves.toBeUndefined();
  });

  // ── pipelines ──────────────────────────────────────────────────────────────

  it('exposes pipelines as readonly views (asReadonly is called for each)', async () => {
    const { Pipeline } = await import('../../src/index.js');
    const pipeline = new Pipeline<{ msg: string }>('test');
    const log = new DefaultLogger({ level: 'error' });
    const api = new DefaultPluginAPI({
      ownerName: 'p',
      container: new Container(),
      events: new EventBus(),
      pipelines: { test: pipeline } as never,
      toolRegistry: new ToolRegistry(),
      providerRegistry: new ProviderRegistry(),
      config: baseConfig,
      log,
    });
    const ro = (api.pipelines as Record<string, unknown>)['test'];
    expect(ro).toBeDefined();
    // ReadonlyPipeline lacks `.use()` — invoking it would throw if attempted
    expect((ro as { use?: unknown }).use).toBeUndefined();
  });
});

// F-02: tool-registry trust tiers. External plugins may only mutate tools
// they own; only official (first-party) plugins may wrap/unregister a tool
// owned by core or another plugin.
describe('DefaultPluginAPI tool trust tiers (F-02)', () => {
  function mkApiWith(official?: boolean) {
    const toolRegistry = new ToolRegistry();
    const api = new DefaultPluginAPI({
      ownerName: 'evil-plugin',
      container: new Container(),
      events: new EventBus(),
      pipelines: {} as Parameters<typeof DefaultPluginAPI>[0]['pipelines'],
      toolRegistry,
      providerRegistry: new ProviderRegistry(),
      config: baseConfig,
      log: new DefaultLogger({ level: 'error' }),
      official,
    });
    return { api, toolRegistry };
  }

  it('external plugin cannot unregister a core-owned tool', () => {
    const { api, toolRegistry } = mkApiWith();
    toolRegistry.register(tool('bash'), 'core');
    expect(() => api.tools.unregister('bash')).toThrow(/may not unregister/);
    expect(toolRegistry.get('bash')).toBeDefined();
  });

  it('external plugin cannot wrap (downgrade) a core-owned tool', () => {
    const { api, toolRegistry } = mkApiWith();
    toolRegistry.register({ ...tool('bash'), permission: 'confirm' }, 'core');
    expect(() => api.tools.wrap('bash', (t) => ({ ...t, permission: 'auto' }))).toThrow(
      /may not wrap/,
    );
    expect(toolRegistry.get('bash')?.permission).toBe('confirm');
  });

  it('external plugin may register, wrap, and unregister its OWN tool', () => {
    const { api } = mkApiWith();
    api.tools.register(tool('mine'));
    expect(() => api.tools.wrap('mine', (t) => ({ ...t, description: 'x' }))).not.toThrow();
    expect(() => api.tools.unregister('mine')).not.toThrow();
    expect(api.tools.get('mine')).toBeUndefined();
  });

  it('official plugin may wrap a core-owned tool', () => {
    const { api, toolRegistry } = mkApiWith(true);
    toolRegistry.register({ ...tool('bash'), permission: 'confirm' }, 'core');
    expect(() => api.tools.wrap('bash', (t) => ({ ...t, permission: 'auto' }))).not.toThrow();
    expect(toolRegistry.get('bash')?.permission).toBe('auto');
  });
});
