import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { join } from 'node:path';
import { resetConfig } from '../../../src/config.js';

vi.mock('node:child_process', () => ({
  execFileSync: vi.fn(),
  spawnSync: vi.fn(),
}));

vi.mock('node:fs', async () => {
  const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
  return {
    ...actual,
    existsSync: vi.fn(),
    readdirSync: vi.fn(),
    readFileSync: vi.fn(),
    mkdirSync: vi.fn(),
    rmSync: vi.fn(),
    statSync: vi.fn(),
  };
});

import { execFileSync } from 'node:child_process';
import { existsSync, readdirSync, readFileSync, mkdirSync, rmSync, statSync } from 'node:fs';
import {
  runPluginAdd,
  runPluginList,
  runPluginRemove,
  runPluginCommand,
} from '../../../src/cli/plugin.js';

describe('runPluginAdd', () => {
  const originalEnv = process.env;
  beforeEach(() => {
    process.env = { ...originalEnv };
    process.env.WIGOLO_PLUGINS_DIR = '/tmp/test-plugins';
    // Bypass the interactive confirm prompt introduced in #171 so the
    // existing happy-path / error-path assertions stay in scope.
    process.env.WIGOLO_PLUGIN_AUTO_YES = '1';
    resetConfig();
    vi.clearAllMocks();
  });
  afterEach(() => {
    process.env = originalEnv;
    resetConfig();
  });

  it('clones a git repo into the plugins directory', async () => {
    vi.mocked(existsSync).mockReturnValue(false);
    vi.mocked(mkdirSync).mockReturnValue(undefined);
    vi.mocked(execFileSync).mockReturnValue(Buffer.from(''));

    await runPluginAdd('https://github.com/user/wigolo-plugin-example.git');

    expect(mkdirSync).toHaveBeenCalledWith('/tmp/test-plugins', { recursive: true });
    expect(execFileSync).toHaveBeenCalledWith(
      'git',
      expect.arrayContaining(['clone', '--depth', '1']),
      expect.objectContaining({ cwd: '/tmp/test-plugins' }),
    );
  });

  it('extracts repo name from git URL for the clone directory', async () => {
    vi.mocked(existsSync).mockReturnValue(false);
    vi.mocked(mkdirSync).mockReturnValue(undefined);
    vi.mocked(execFileSync).mockReturnValue(Buffer.from(''));

    await runPluginAdd('https://github.com/user/my-plugin.git');

    expect(execFileSync).toHaveBeenCalledWith(
      'git',
      expect.arrayContaining(['my-plugin']),
      expect.anything(),
    );
  });

  it('extracts repo name from URL without .git suffix', async () => {
    vi.mocked(existsSync).mockReturnValue(false);
    vi.mocked(mkdirSync).mockReturnValue(undefined);
    vi.mocked(execFileSync).mockReturnValue(Buffer.from(''));

    await runPluginAdd('https://github.com/user/no-git-suffix');

    expect(execFileSync).toHaveBeenCalledWith(
      'git',
      expect.arrayContaining(['no-git-suffix']),
      expect.anything(),
    );
  });

  it('throws if plugin directory already exists', async () => {
    vi.mocked(existsSync).mockReturnValue(true);

    await expect(runPluginAdd('https://github.com/user/my-plugin.git')).rejects.toThrow(
      /already exists/i,
    );
  });

  it('throws on git clone failure', async () => {
    vi.mocked(existsSync).mockReturnValue(false);
    vi.mocked(mkdirSync).mockReturnValue(undefined);
    vi.mocked(execFileSync).mockImplementation(() => {
      throw new Error('fatal: repository not found');
    });

    await expect(runPluginAdd('https://github.com/user/nonexistent.git')).rejects.toThrow(
      /clone failed/i,
    );
  });

  it('throws on empty git URL', async () => {
    await expect(runPluginAdd('')).rejects.toThrow(/url/i);
  });

  it('throws on malformed git URL without path segments', async () => {
    await expect(runPluginAdd('not-a-url')).rejects.toThrow();
  });

  it('handles SSH-style git URLs (git@github.com:user/repo.git)', async () => {
    vi.mocked(existsSync).mockReturnValue(false);
    vi.mocked(mkdirSync).mockReturnValue(undefined);
    vi.mocked(execFileSync).mockReturnValue(Buffer.from(''));

    await runPluginAdd('git@github.com:user/ssh-plugin.git');

    expect(execFileSync).toHaveBeenCalledWith(
      'git',
      expect.arrayContaining(['ssh-plugin']),
      expect.anything(),
    );
  });

  it('refuses to install when stdin is not a TTY and no --yes flag is set', async () => {
    delete process.env.WIGOLO_PLUGIN_AUTO_YES;
    const originalIsTTY = process.stdin.isTTY;
    Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
    vi.mocked(existsSync).mockReturnValue(false);
    try {
      await expect(
        runPluginAdd('https://github.com/user/evil-plugin.git'),
      ).rejects.toThrow(/non-interactively/i);
      expect(execFileSync).not.toHaveBeenCalled();
    } finally {
      Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true });
      process.env.WIGOLO_PLUGIN_AUTO_YES = '1';
    }
  });

  it('skips the confirm prompt when --yes is passed', async () => {
    delete process.env.WIGOLO_PLUGIN_AUTO_YES;
    vi.mocked(existsSync).mockReturnValue(false);
    vi.mocked(mkdirSync).mockReturnValue(undefined);
    vi.mocked(execFileSync).mockReturnValue(Buffer.from(''));

    await runPluginAdd('https://github.com/user/trusted-plugin.git', { assumeYes: true });
    expect(execFileSync).toHaveBeenCalledWith(
      'git',
      expect.arrayContaining(['clone']),
      expect.anything(),
    );
  });
});

describe('runPluginList', () => {
  const originalEnv = process.env;
  let stderrOutput: string;

  beforeEach(() => {
    process.env = { ...originalEnv };
    process.env.WIGOLO_PLUGINS_DIR = '/tmp/test-plugins';
    resetConfig();
    vi.clearAllMocks();
    stderrOutput = '';
    vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
      stderrOutput += String(chunk);
      return true;
    });
  });
  afterEach(() => {
    process.env = originalEnv;
    resetConfig();
    vi.restoreAllMocks();
  });

  it('lists installed plugins with name and version', () => {
    vi.mocked(existsSync).mockReturnValue(true);
    vi.mocked(readdirSync).mockReturnValue(['plugin-a', 'plugin-b'] as any);
    vi.mocked(statSync).mockReturnValue({ isDirectory: () => true, isSymbolicLink: () => false } as any);
    vi.mocked(readFileSync).mockImplementation((p) => {
      if (String(p).includes('plugin-a')) {
        return JSON.stringify({ name: 'plugin-a', version: '1.0.0', main: 'index.mjs' });
      }
      return JSON.stringify({ name: 'plugin-b', version: '2.3.0', main: 'index.mjs' });
    });

    runPluginList();

    expect(stderrOutput).toContain('plugin-a');
    expect(stderrOutput).toContain('1.0.0');
    expect(stderrOutput).toContain('plugin-b');
    expect(stderrOutput).toContain('2.3.0');
  });

  it('shows a message when no plugins are installed', () => {
    vi.mocked(existsSync).mockReturnValue(false);

    runPluginList();

    expect(stderrOutput).toContain('no plugins');
  });

  it('handles empty plugins directory', () => {
    vi.mocked(existsSync).mockReturnValue(true);
    vi.mocked(readdirSync).mockReturnValue([] as any);

    runPluginList();

    expect(stderrOutput).toContain('no plugins');
  });

  it('skips non-directory entries', () => {
    vi.mocked(existsSync).mockReturnValue(true);
    vi.mocked(readdirSync).mockReturnValue(['file.txt', 'plugin-a'] as any);
    vi.mocked(statSync).mockImplementation((p) => {
      if (String(p).includes('file.txt')) {
        return { isDirectory: () => false, isSymbolicLink: () => false } as any;
      }
      return { isDirectory: () => true, isSymbolicLink: () => false } as any;
    });
    vi.mocked(readFileSync).mockReturnValue(
      JSON.stringify({ name: 'plugin-a', version: '1.0.0', main: 'index.mjs' }),
    );

    runPluginList();

    expect(stderrOutput).toContain('plugin-a');
    expect(stderrOutput).not.toContain('file.txt');
  });

  it('handles plugin with malformed package.json gracefully', () => {
    vi.mocked(existsSync).mockReturnValue(true);
    vi.mocked(readdirSync).mockReturnValue(['bad-plugin'] as any);
    vi.mocked(statSync).mockReturnValue({ isDirectory: () => true, isSymbolicLink: () => false } as any);
    vi.mocked(readFileSync).mockImplementation(() => { throw new Error('parse error'); });

    runPluginList();

    expect(stderrOutput).toContain('bad-plugin');
  });
});

describe('runPluginRemove', () => {
  const originalEnv = process.env;
  beforeEach(() => {
    process.env = { ...originalEnv };
    process.env.WIGOLO_PLUGINS_DIR = '/tmp/test-plugins';
    resetConfig();
    vi.clearAllMocks();
  });
  afterEach(() => {
    process.env = originalEnv;
    resetConfig();
  });

  it('removes the plugin directory', () => {
    vi.mocked(existsSync).mockReturnValue(true);
    vi.mocked(rmSync).mockReturnValue(undefined);

    runPluginRemove('my-plugin');

    expect(rmSync).toHaveBeenCalledWith(
      expect.stringContaining('my-plugin'),
      { recursive: true, force: true },
    );
  });

  it('throws when plugin does not exist', () => {
    vi.mocked(existsSync).mockReturnValue(false);

    expect(() => runPluginRemove('nonexistent')).toThrow(/not found/i);
  });

  it('throws on empty name', () => {
    expect(() => runPluginRemove('')).toThrow(/name/i);
  });

  it('prevents path traversal in plugin name', () => {
    expect(() => runPluginRemove('../etc')).toThrow(/invalid/i);
  });

  it('prevents absolute path in plugin name', () => {
    expect(() => runPluginRemove('/etc/passwd')).toThrow(/invalid/i);
  });

  it('handles plugin names with dashes and underscores', () => {
    vi.mocked(existsSync).mockReturnValue(true);
    vi.mocked(rmSync).mockReturnValue(undefined);

    runPluginRemove('my-cool_plugin');

    expect(rmSync).toHaveBeenCalledWith(
      expect.stringContaining('my-cool_plugin'),
      expect.anything(),
    );
  });

  it('throws on removal failure', () => {
    vi.mocked(existsSync).mockReturnValue(true);
    vi.mocked(rmSync).mockImplementation(() => { throw new Error('EACCES'); });

    expect(() => runPluginRemove('locked-plugin')).toThrow(/remove.*failed/i);
  });
});

describe('runPluginCommand -- dispatcher', () => {
  const originalEnv = process.env;
  let stderrOutput: string;

  beforeEach(() => {
    process.env = { ...originalEnv };
    process.env.WIGOLO_PLUGINS_DIR = '/tmp/test-plugins';
    // Bypass the interactive confirm prompt introduced in #171.
    process.env.WIGOLO_PLUGIN_AUTO_YES = '1';
    resetConfig();
    vi.clearAllMocks();
    stderrOutput = '';
    vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
      stderrOutput += String(chunk);
      return true;
    });
  });
  afterEach(() => {
    process.env = originalEnv;
    resetConfig();
    vi.restoreAllMocks();
  });

  it('routes "add" subcommand', async () => {
    vi.mocked(existsSync).mockReturnValue(false);
    vi.mocked(mkdirSync).mockReturnValue(undefined);
    vi.mocked(execFileSync).mockReturnValue(Buffer.from(''));

    await runPluginCommand(['add', 'https://github.com/user/repo.git']);

    expect(execFileSync).toHaveBeenCalledWith(
      'git',
      expect.arrayContaining(['clone']),
      expect.anything(),
    );
  });

  it('routes "list" subcommand', async () => {
    vi.mocked(existsSync).mockReturnValue(false);

    await runPluginCommand(['list']);

    expect(stderrOutput).toContain('no plugins');
  });

  it('routes "remove" subcommand', async () => {
    vi.mocked(existsSync).mockReturnValue(true);
    vi.mocked(rmSync).mockReturnValue(undefined);

    await runPluginCommand(['remove', 'my-plugin']);

    expect(rmSync).toHaveBeenCalled();
  });

  it('shows usage for unknown subcommand', async () => {
    const code = await runPluginCommand(['unknown']);

    expect(stderrOutput).toContain('Usage');
    expect(code).toBe(1);
  });

  it('shows usage when no args provided', async () => {
    const code = await runPluginCommand([]);

    expect(stderrOutput).toContain('Usage');
    expect(code).toBe(1);
  });

  it('routes "validate" and exits 0 when every installed plugin is well-formed', async () => {
    // pluginsDir exists, one plugin dir with a package.json whose main file exists.
    vi.mocked(existsSync).mockImplementation((p) => {
      const s = String(p);
      return s.endsWith('/test-plugins') || s.endsWith('package.json') || s.endsWith('index.mjs');
    });
    vi.mocked(readdirSync).mockReturnValue(['good'] as never);
    vi.mocked(statSync).mockReturnValue({ isDirectory: () => true, isSymbolicLink: () => false } as never);
    vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ name: 'good', version: '1.0.0', main: 'index.mjs' }) as never);

    const code = await runPluginCommand(['validate']);
    expect(code).toBe(0);
  });

  it('routes "validate" and exits 1 when a plugin fails validation (missing main file)', async () => {
    // The "main" file does NOT exist on disk — a broken plugin the CLI must
    // surface as a non-zero exit so `plugin validate --json` scripting works.
    vi.mocked(existsSync).mockImplementation((p) => {
      const s = String(p);
      // dir + package.json present, but the referenced main file is absent.
      return s.endsWith('/test-plugins') || s.endsWith('package.json');
    });
    vi.mocked(readdirSync).mockReturnValue(['bad'] as never);
    vi.mocked(statSync).mockReturnValue({ isDirectory: () => true, isSymbolicLink: () => false } as never);
    vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ name: 'bad', version: '1.0.0', main: 'missing.mjs' }) as never);

    const code = await runPluginCommand(['validate']);
    expect(code).toBe(1);
  });

  it('routes "validate --json" on a bad plugin → exit 1 + exactly one {status:"error"} JSON doc on stdout', async () => {
    vi.mocked(existsSync).mockImplementation((p) => {
      const s = String(p);
      return s.endsWith('/test-plugins') || s.endsWith('package.json');
    });
    vi.mocked(readdirSync).mockReturnValue(['bad'] as never);
    vi.mocked(statSync).mockReturnValue({ isDirectory: () => true, isSymbolicLink: () => false } as never);
    vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ name: 'bad', version: '1.0.0', main: 'missing.mjs' }) as never);

    // Capture stdout: under --json the whole result is ONE JSON document there.
    const stdoutChunks: string[] = [];
    vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
      stdoutChunks.push(String(chunk));
      return true;
    });

    const code = await runPluginCommand(['validate', '--json']);
    expect(code).toBe(1);

    const lines = stdoutChunks.join('').trim().split('\n').filter((l) => l.trim().length > 0);
    expect(lines).toHaveLength(1);
    const doc = JSON.parse(lines[0]) as { status: string; plugins: Array<{ valid: boolean }> };
    expect(doc.status).toBe('error');
    expect(doc.plugins[0].valid).toBe(false);
  });
});