import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { parse as parseJsonc } from 'jsonc-parser';
import { createRequire } from 'module';

const PKG_VERSION = (createRequire(import.meta.url)('../../package.json') as { version: string })
  .version;
const NPX_REF = `gitnexus@${PKG_VERSION}`;

const execFileMock = vi.fn((...args: any[]) => {
  const callback = args.at(-1);
  if (typeof callback === 'function') {
    callback(null, '', '');
  }
});

const execFileSyncMock = vi.fn(() => {
  throw new Error('not found');
});

vi.mock('child_process', () => ({
  execFile: execFileMock,
  execFileSync: execFileSyncMock,
}));

describe('setupOpenCode — JSONC preservation', () => {
  let tempHome: string;
  let originalHome: string | undefined;
  let originalUserProfile: string | undefined;
  let platformDescriptor: PropertyDescriptor | undefined;

  const setPlatform = (value: NodeJS.Platform) => {
    Object.defineProperty(process, 'platform', {
      value,
      configurable: true,
    });
  };

  const opencodeDir = () => path.join(tempHome, '.config', 'opencode');
  const opencodeJsonPath = () => path.join(opencodeDir(), 'opencode.json');

  beforeEach(async () => {
    vi.resetModules();
    vi.clearAllMocks();

    originalHome = process.env.HOME;
    originalUserProfile = process.env.USERPROFILE;
    tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-opencode-jsonc-'));
    process.env.HOME = tempHome;
    process.env.USERPROFILE = tempHome;

    await fs.mkdir(opencodeDir(), { recursive: true });

    platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
    setPlatform('linux');
    vi.spyOn(console, 'log').mockImplementation(() => {});
  });

  afterEach(async () => {
    vi.restoreAllMocks();

    if (platformDescriptor) {
      Object.defineProperty(process, 'platform', platformDescriptor);
    }

    process.env.HOME = originalHome;
    process.env.USERPROFILE = originalUserProfile;
    await fs.rm(tempHome, { recursive: true, force: true });
  });

  it('preserves line comments (//)', async () => {
    const jsonc = `{
  // This comment must survive
  "model": "test"
}`;
    await fs.writeFile(opencodeJsonPath(), jsonc, 'utf-8');

    const { setupCommand } = await import('../../src/cli/setup.js');
    await setupCommand();

    const raw = await fs.readFile(opencodeJsonPath(), 'utf-8');
    expect(raw).toContain('This comment must survive');

    const config = parseJsonc(raw);
    expect(config.mcp.gitnexus).toBeDefined();
    expect(config.model).toBe('test');
  });

  it('preserves block comments (/* */)', async () => {
    const jsonc = `{
  /* block comment */
  "model": "test"
}`;
    await fs.writeFile(opencodeJsonPath(), jsonc, 'utf-8');

    const { setupCommand } = await import('../../src/cli/setup.js');
    await setupCommand();

    const raw = await fs.readFile(opencodeJsonPath(), 'utf-8');
    expect(raw).toContain('block comment');

    const config = parseJsonc(raw);
    expect(config.mcp.gitnexus).toBeDefined();
    expect(config.model).toBe('test');
  });

  it('preserves trailing comments', async () => {
    const jsonc = `{
  "model": "test", // inline comment
  "provider": "anthropic"
}`;
    await fs.writeFile(opencodeJsonPath(), jsonc, 'utf-8');

    const { setupCommand } = await import('../../src/cli/setup.js');
    await setupCommand();

    const raw = await fs.readFile(opencodeJsonPath(), 'utf-8');
    expect(raw).toContain('inline comment');

    const config = parseJsonc(raw);
    expect(config.model).toBe('test');
    expect(config.provider).toBe('anthropic');
    expect(config.mcp.gitnexus).toBeDefined();
  });

  it('handles plain JSON without comments (backwards compatible)', async () => {
    const plain = JSON.stringify({ model: 'test', provider: 'openai' }, null, 2);
    await fs.writeFile(opencodeJsonPath(), plain, 'utf-8');

    const { setupCommand } = await import('../../src/cli/setup.js');
    await setupCommand();

    const raw = await fs.readFile(opencodeJsonPath(), 'utf-8');
    const config = parseJsonc(raw);

    expect(config.model).toBe('test');
    expect(config.provider).toBe('openai');
    expect(config.mcp.gitnexus).toBeDefined();
  });

  it('handles missing opencode.json (creates fresh)', async () => {
    await fs.rm(opencodeJsonPath(), { force: true });

    const { setupCommand } = await import('../../src/cli/setup.js');
    await setupCommand();

    const raw = await fs.readFile(opencodeJsonPath(), 'utf-8');
    const config = parseJsonc(raw);

    expect(config.mcp.gitnexus).toBeDefined();
  });

  it('preserves all existing top-level keys', async () => {
    const jsonc = `{
  // my config
  "model": "claude-sonnet",
  "instructions": "Be helpful",
  "plugin": ["foo"],
  "provider": "anthropic",
  "mcp": { "other": { "command": "bar" } }
}`;
    await fs.writeFile(opencodeJsonPath(), jsonc, 'utf-8');

    const { setupCommand } = await import('../../src/cli/setup.js');
    await setupCommand();

    const raw = await fs.readFile(opencodeJsonPath(), 'utf-8');
    expect(raw).toContain('my config');

    const config = parseJsonc(raw);
    expect(config.model).toBe('claude-sonnet');
    expect(config.instructions).toBe('Be helpful');
    expect(config.plugin).toEqual(['foo']);
    expect(config.provider).toBe('anthropic');
    expect(config.mcp.other).toEqual({ command: 'bar' });
    expect(config.mcp.gitnexus).toBeDefined();
  });

  it('updates existing gitnexus MCP entry without losing other keys', async () => {
    execFileSyncMock.mockReturnValueOnce('/usr/local/bin/gitnexus\n');

    const jsonc = `{
  // config comment
  "model": "test",
  "mcp": {
    "other": { "command": "keep" },
    "gitnexus": { "command": "old-gitnexus", "args": ["old"] }
  }
}`;
    await fs.writeFile(opencodeJsonPath(), jsonc, 'utf-8');

    const { setupCommand } = await import('../../src/cli/setup.js');
    await setupCommand();

    const raw = await fs.readFile(opencodeJsonPath(), 'utf-8');
    expect(raw).toContain('config comment');

    const config = parseJsonc(raw);
    expect(config.model).toBe('test');
    expect(config.mcp.other).toEqual({ command: 'keep' });
    expect(config.mcp.gitnexus).toEqual({
      type: 'local',
      command: ['/usr/local/bin/gitnexus', 'mcp'],
    });
  });

  it('does not wipe corrupt file content', async () => {
    const corrupt = '{ "model": "test" this is broken {{{';
    await fs.writeFile(opencodeJsonPath(), corrupt, 'utf-8');

    const { setupCommand } = await import('../../src/cli/setup.js');
    await setupCommand();

    const raw = await fs.readFile(opencodeJsonPath(), 'utf-8');
    expect(raw).toBe(corrupt);
    expect(raw).not.toContain('gitnexus');
  });

  it('uses npx fallback format when gitnexus binary is not on PATH', async () => {
    execFileSyncMock.mockImplementation(() => {
      throw new Error('not found');
    });

    const jsonc = `{
  "model": "test",
  "mcp": {}
}`;
    await fs.writeFile(opencodeJsonPath(), jsonc, 'utf-8');

    const { setupCommand } = await import('../../src/cli/setup.js');
    await setupCommand();

    const raw = await fs.readFile(opencodeJsonPath(), 'utf-8');
    const config = parseJsonc(raw);

    expect(config.mcp.gitnexus).toEqual({
      type: 'local',
      command: ['npx', '-y', NPX_REF, 'mcp'],
    });
  });

  it('uses Windows npx fallback when where returns only a non-wrapper shim', async () => {
    setPlatform('win32');
    execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\n');

    const jsonc = `{
  "model": "test",
  "mcp": {}
}`;
    await fs.writeFile(opencodeJsonPath(), jsonc, 'utf-8');

    const { setupCommand } = await import('../../src/cli/setup.js');
    await setupCommand();

    const raw = await fs.readFile(opencodeJsonPath(), 'utf-8');
    const config = parseJsonc(raw);

    expect(config.mcp.gitnexus).toEqual({
      type: 'local',
      command: ['cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'],
    });
  });

  it('uses Windows npx fallback when where returns only a .ps1 path', async () => {
    setPlatform('win32');
    execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus.ps1\n');

    const jsonc = `{
  "model": "test",
  "mcp": {}
}`;
    await fs.writeFile(opencodeJsonPath(), jsonc, 'utf-8');

    const { setupCommand } = await import('../../src/cli/setup.js');
    await setupCommand();

    const raw = await fs.readFile(opencodeJsonPath(), 'utf-8');
    const config = parseJsonc(raw);

    expect(config.mcp.gitnexus).toEqual({
      type: 'local',
      command: ['cmd', '/c', 'npx', '-y', NPX_REF, 'mcp'],
    });
  });

  it('preserves tab indentation in existing file', async () => {
    const tabbed = `{\n\t"model": "test"\n}`;
    await fs.writeFile(opencodeJsonPath(), tabbed, 'utf-8');

    const { setupCommand } = await import('../../src/cli/setup.js');
    await setupCommand();

    const raw = await fs.readFile(opencodeJsonPath(), 'utf-8');
    expect(raw).toContain('\t"model"');
    expect(raw).toContain('\t"gitnexus"');
  });

  it('preserves 4-space indentation in existing file', async () => {
    const fourSpace = `{
    "model": "test"
}`;
    await fs.writeFile(opencodeJsonPath(), fourSpace, 'utf-8');

    const { setupCommand } = await import('../../src/cli/setup.js');
    await setupCommand();

    const raw = await fs.readFile(opencodeJsonPath(), 'utf-8');
    const mcpLine = raw.split('\n').find((l) => l.includes('"gitnexus"'));
    expect(mcpLine).toMatch(/^    /);
  });

  it('skips when ~/.config/opencode directory does not exist', async () => {
    await fs.rm(opencodeDir(), { recursive: true, force: true });

    const { setupCommand } = await import('../../src/cli/setup.js');
    await setupCommand();

    await expect(fs.access(opencodeJsonPath())).rejects.toThrow();
  });
});

describe('setupCursor — JSONC preservation', () => {
  let tempHome: string;
  let originalHome: string | undefined;
  let originalUserProfile: string | undefined;
  let platformDescriptor: PropertyDescriptor | undefined;

  const setPlatform = (value: NodeJS.Platform) => {
    Object.defineProperty(process, 'platform', {
      value,
      configurable: true,
    });
  };

  const cursorDir = () => path.join(tempHome, '.cursor');
  const mcpPath = () => path.join(cursorDir(), 'mcp.json');

  beforeEach(async () => {
    vi.resetModules();
    vi.clearAllMocks();

    originalHome = process.env.HOME;
    originalUserProfile = process.env.USERPROFILE;
    tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-cursor-jsonc-'));
    process.env.HOME = tempHome;
    process.env.USERPROFILE = tempHome;

    await fs.mkdir(cursorDir(), { recursive: true });

    platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
    setPlatform('linux');
    vi.spyOn(console, 'log').mockImplementation(() => {});
  });

  afterEach(async () => {
    vi.restoreAllMocks();

    if (platformDescriptor) {
      Object.defineProperty(process, 'platform', platformDescriptor);
    }

    process.env.HOME = originalHome;
    process.env.USERPROFILE = originalUserProfile;
    await fs.rm(tempHome, { recursive: true, force: true });
  });

  it('creates fresh mcp.json when missing', async () => {
    const { setupCommand } = await import('../../src/cli/setup.js');
    await setupCommand();

    const raw = await fs.readFile(mcpPath(), 'utf-8');
    const config = JSON.parse(raw);
    expect(config.mcpServers.gitnexus).toBeDefined();
  });

  it('preserves existing mcpServers and comments', async () => {
    const jsonc = `{
  // my cursor config
  "mcpServers": {
    "other": { "command": "keep" }
  }
}`;
    await fs.writeFile(mcpPath(), jsonc, 'utf-8');

    const { setupCommand } = await import('../../src/cli/setup.js');
    await setupCommand();

    const raw = await fs.readFile(mcpPath(), 'utf-8');
    expect(raw).toContain('my cursor config');
    const config = parseJsonc(raw);
    expect(config.mcpServers.other).toEqual({ command: 'keep' });
    expect(config.mcpServers.gitnexus).toBeDefined();
  });

  it('does not wipe corrupt file', async () => {
    const corrupt = '{ "mcpServers": broken';
    await fs.writeFile(mcpPath(), corrupt, 'utf-8');

    const { setupCommand } = await import('../../src/cli/setup.js');
    await setupCommand();

    const raw = await fs.readFile(mcpPath(), 'utf-8');
    expect(raw).toBe(corrupt);
  });

  it('uses Windows npx fallback when where returns only a non-wrapper shim', async () => {
    setPlatform('win32');
    execFileSyncMock.mockReturnValueOnce('C:\\Users\\dev\\AppData\\Roaming\\npm\\gitnexus\n');

    const { setupCommand } = await import('../../src/cli/setup.js');
    await setupCommand();

    const raw = await fs.readFile(mcpPath(), 'utf-8');
    const config = parseJsonc(raw);

    expect(config.mcpServers.gitnexus).toEqual({
      command: 'cmd',
      args: ['/c', 'npx', '-y', NPX_REF, 'mcp'],
    });
  });
});

describe('setupClaudeCode — JSONC preservation', () => {
  let tempHome: string;
  let originalHome: string | undefined;
  let originalUserProfile: string | undefined;
  let platformDescriptor: PropertyDescriptor | undefined;

  const setPlatform = (value: NodeJS.Platform) => {
    Object.defineProperty(process, 'platform', {
      value,
      configurable: true,
    });
  };

  const claudeDir = () => path.join(tempHome, '.claude');
  const mcpPath = () => path.join(tempHome, '.claude.json');

  beforeEach(async () => {
    vi.resetModules();
    vi.clearAllMocks();

    originalHome = process.env.HOME;
    originalUserProfile = process.env.USERPROFILE;
    tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-claude-jsonc-'));
    process.env.HOME = tempHome;
    process.env.USERPROFILE = tempHome;

    await fs.mkdir(claudeDir(), { recursive: true });

    platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
    setPlatform('linux');
    vi.spyOn(console, 'log').mockImplementation(() => {});
  });

  afterEach(async () => {
    vi.restoreAllMocks();

    if (platformDescriptor) {
      Object.defineProperty(process, 'platform', platformDescriptor);
    }

    process.env.HOME = originalHome;
    process.env.USERPROFILE = originalUserProfile;
    await fs.rm(tempHome, { recursive: true, force: true });
  });

  it('creates fresh .claude.json when missing', async () => {
    const { setupCommand } = await import('../../src/cli/setup.js');
    await setupCommand();

    const raw = await fs.readFile(mcpPath(), 'utf-8');
    const config = JSON.parse(raw);
    expect(config.mcpServers.gitnexus).toBeDefined();
  });

  it('preserves existing keys and comments', async () => {
    const jsonc = `{
  // my claude config
  "permissions": ["read"],
  "mcpServers": {
    "other": { "command": "keep" }
  }
}`;
    await fs.writeFile(mcpPath(), jsonc, 'utf-8');

    const { setupCommand } = await import('../../src/cli/setup.js');
    await setupCommand();

    const raw = await fs.readFile(mcpPath(), 'utf-8');
    expect(raw).toContain('my claude config');
    const config = parseJsonc(raw);
    expect(config.permissions).toEqual(['read']);
    expect(config.mcpServers.other).toEqual({ command: 'keep' });
    expect(config.mcpServers.gitnexus).toBeDefined();
  });

  it('does not wipe corrupt file', async () => {
    const corrupt = '{ "permissions": broken';
    await fs.writeFile(mcpPath(), corrupt, 'utf-8');

    const { setupCommand } = await import('../../src/cli/setup.js');
    await setupCommand();

    const raw = await fs.readFile(mcpPath(), 'utf-8');
    expect(raw).toBe(corrupt);
  });
});

describe('installClaudeCodeHooks — JSONC preservation', () => {
  let tempHome: string;
  let originalHome: string | undefined;
  let originalUserProfile: string | undefined;
  let platformDescriptor: PropertyDescriptor | undefined;

  const setPlatform = (value: NodeJS.Platform) => {
    Object.defineProperty(process, 'platform', {
      value,
      configurable: true,
    });
  };

  const claudeDir = () => path.join(tempHome, '.claude');
  const settingsPath = () => path.join(claudeDir(), 'settings.json');

  beforeEach(async () => {
    vi.resetModules();
    vi.clearAllMocks();

    originalHome = process.env.HOME;
    originalUserProfile = process.env.USERPROFILE;
    tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-hooks-jsonc-'));
    process.env.HOME = tempHome;
    process.env.USERPROFILE = tempHome;

    await fs.mkdir(claudeDir(), { recursive: true });

    platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
    setPlatform('linux');
    vi.spyOn(console, 'log').mockImplementation(() => {});
  });

  afterEach(async () => {
    vi.restoreAllMocks();

    if (platformDescriptor) {
      Object.defineProperty(process, 'platform', platformDescriptor);
    }

    process.env.HOME = originalHome;
    process.env.USERPROFILE = originalUserProfile;
    await fs.rm(tempHome, { recursive: true, force: true });
  });

  it('creates fresh settings.json with hooks when missing', async () => {
    const { setupCommand } = await import('../../src/cli/setup.js');
    await setupCommand();

    const raw = await fs.readFile(settingsPath(), 'utf-8');
    const config = JSON.parse(raw);
    expect(config.hooks.PreToolUse).toBeDefined();
    expect(config.hooks.PostToolUse).toBeDefined();
    expect(config.hooks.PreToolUse[0].matcher).toBe('Grep|Glob|Bash');
  });

  it('appends hooks to existing settings preserving comments', async () => {
    const jsonc = `{
  // my settings
  "permissions": ["read"],
  "hooks": {
    "PreToolUse": [
      { "matcher": "Write", "hooks": [{ "type": "command", "command": "other-hook" }] }
    ]
  }
}`;
    await fs.writeFile(settingsPath(), jsonc, 'utf-8');

    const { setupCommand } = await import('../../src/cli/setup.js');
    await setupCommand();

    const raw = await fs.readFile(settingsPath(), 'utf-8');
    expect(raw).toContain('my settings');
    const config = parseJsonc(raw);
    expect(config.permissions).toEqual(['read']);
    expect(config.hooks.PreToolUse.length).toBe(2);
    expect(config.hooks.PreToolUse[0].matcher).toBe('Write');
    expect(config.hooks.PreToolUse[1].hooks[0].command).toContain('gitnexus-hook');
    expect(config.hooks.PostToolUse).toBeDefined();
  });

  it('does not add duplicate gitnexus-hook entries', async () => {
    const jsonc = JSON.stringify(
      {
        hooks: {
          PreToolUse: [
            {
              matcher: 'Grep|Glob|Bash',
              hooks: [{ type: 'command', command: 'node "gitnexus-hook.cjs"', timeout: 10 }],
            },
          ],
          PostToolUse: [
            {
              matcher: 'Bash',
              hooks: [{ type: 'command', command: 'node "gitnexus-hook.cjs"', timeout: 10 }],
            },
          ],
        },
      },
      null,
      2,
    );
    await fs.writeFile(settingsPath(), jsonc, 'utf-8');

    const { setupCommand } = await import('../../src/cli/setup.js');
    await setupCommand();

    const raw = await fs.readFile(settingsPath(), 'utf-8');
    const config = JSON.parse(raw);
    expect(config.hooks.PreToolUse.length).toBe(1);
    expect(config.hooks.PostToolUse.length).toBe(1);
  });

  it('does not wipe corrupt file', async () => {
    const corrupt = '{ "hooks": broken';
    await fs.writeFile(settingsPath(), corrupt, 'utf-8');

    const { setupCommand } = await import('../../src/cli/setup.js');
    await setupCommand();

    const raw = await fs.readFile(settingsPath(), 'utf-8');
    expect(raw).toBe(corrupt);
  });

  it('handles idempotency check with JSONC comments in settings', async () => {
    const jsonc = `{
  // settings comment
  "hooks": {
    "PreToolUse": [
      { "matcher": "Grep|Glob|Bash", "hooks": [{ "type": "command", "command": "other-hook" }] }
    ]
  }
}`;
    await fs.writeFile(settingsPath(), jsonc, 'utf-8');

    const { setupCommand } = await import('../../src/cli/setup.js');
    await setupCommand();

    const raw = await fs.readFile(settingsPath(), 'utf-8');
    expect(raw).toContain('settings comment');
    const config = parseJsonc(raw);
    expect(config.hooks.PreToolUse.length).toBe(2);
    expect(config.hooks.PostToolUse.length).toBe(1);
  });
});
