import { describe, it, expect } from 'vitest';
import {
  validateLlamaConfig,
  computeDiff,
  backupFilename,
  applyRemoteConfig,
  healthWait,
  type SshExec,
  type ExecResult,
} from '../ssh-config.js';

// A minimal subset of the llama-swap config schema sufficient for these tests:
// top-level object with a required non-empty `models` object.
const SCHEMA = {
  type: 'object',
  required: ['models'],
  properties: {
    models: {
      type: 'object',
      minProperties: 1,
      additionalProperties: {
        type: 'object',
        properties: { cmd: { type: 'string' } },
      },
    },
  },
} as const;

const VALID_YAML = `models:\n  m1:\n    cmd: "llama-server -m m1.gguf"\n`;

describe('validateLlamaConfig', () => {
  it('accepts a valid config', () => {
    const r = validateLlamaConfig(VALID_YAML, SCHEMA);
    expect(r.valid).toBe(true);
    expect(r.errors).toEqual([]);
  });

  it('rejects broken YAML with a parse error', () => {
    const r = validateLlamaConfig('models:\n  m1:\n   cmd: "x\n  : :', SCHEMA);
    expect(r.valid).toBe(false);
    expect(r.errors[0]).toMatch(/YAML parse error/);
  });

  it('rejects a config missing required models', () => {
    const r = validateLlamaConfig('healthCheckTimeout: 30\n', SCHEMA);
    expect(r.valid).toBe(false);
    expect(r.errors.join(' ')).toMatch(/models/);
  });

  it('rejects a non-mapping document', () => {
    const r = validateLlamaConfig('- just\n- a\n- list\n', SCHEMA);
    expect(r.valid).toBe(false);
  });
});

describe('computeDiff', () => {
  it('returns empty for identical text', () => {
    expect(computeDiff('a\nb\n', 'a\nb\n')).toBe('');
  });
  it('marks changed lines with -/+', () => {
    const d = computeDiff('a\nb\nc\n', 'a\nX\nc\n');
    expect(d).toContain('- b');
    expect(d).toContain('+ X');
  });
});

describe('backupFilename', () => {
  it('produces a timestamped path', () => {
    const name = backupFilename('/etc/llama/config.yaml', new Date('2026-06-12T03:04:05.678Z'));
    expect(name).toBe('/etc/llama/config.yaml.bak-20260612T030405Z');
  });
});

// ─── apply pipeline failure paths ────────────────────────────────────────────

function makeExec(handlers: Record<string, ExecResult>): { exec: SshExec; calls: string[] } {
  const calls: string[] = [];
  const exec: SshExec = async (_t, command) => {
    calls.push(command);
    for (const [pattern, result] of Object.entries(handlers)) {
      if (command.includes(pattern)) return result;
    }
    return { code: 0, stdout: '', stderr: '' };
  };
  return { exec, calls };
}

const target = { host: 'h', user: 'u', keyPath: '/k' };
const okFetcher = (async () => new Response('{}', { status: 200 })) as unknown as typeof fetch;

describe('applyRemoteConfig', () => {
  it('aborts at validate for an invalid config and never touches the host', async () => {
    const { exec, calls } = makeExec({});
    const r = await applyRemoteConfig({
      target, configPath: '/c.yaml', restartCmd: 'restart', newConfig: 'not: valid: yaml: here:::',
      schema: SCHEMA, baseUrl: 'http://h', exec, fetcher: okFetcher,
    });
    expect(r.ok).toBe(false);
    expect(r.step).toBe('validate');
    expect(calls).toHaveLength(0);
  });

  it('aborts at validate when the host config is unreadable', async () => {
    const { exec } = makeExec({ "cat '": { code: 1, stdout: '', stderr: 'no such file' } });
    const r = await applyRemoteConfig({
      target, configPath: '/c.yaml', restartCmd: 'restart', newConfig: VALID_YAML,
      schema: SCHEMA, baseUrl: 'http://h', exec, fetcher: okFetcher,
    });
    expect(r.ok).toBe(false);
    expect(r.step).toBe('validate');
    expect(r.error).toMatch(/read current failed/);
  });

  it('backs up BEFORE write and aborts on write failure (backup retained)', async () => {
    const { exec, calls } = makeExec({
      "cat '": { code: 0, stdout: 'models:\n  old: {}\n', stderr: '' }, // read current
      'cp ': { code: 0, stdout: '', stderr: '' },                      // backup
      'cat >': { code: 1, stdout: '', stderr: 'disk full' },           // write fails
    });
    const r = await applyRemoteConfig({
      target, configPath: '/c.yaml', restartCmd: 'restart', newConfig: VALID_YAML,
      schema: SCHEMA, baseUrl: 'http://h', exec, fetcher: okFetcher,
      now: new Date('2026-06-12T00:00:00Z'),
    });
    expect(r.ok).toBe(false);
    expect(r.step).toBe('write');
    expect(r.backupPath).toBe('/c.yaml.bak-20260612T000000Z');
    // backup (cp) must precede write (cat >)
    const cpIdx = calls.findIndex((c) => c.startsWith('cp '));
    const writeIdx = calls.findIndex((c) => c.startsWith('cat >'));
    expect(cpIdx).toBeGreaterThanOrEqual(0);
    expect(writeIdx).toBeGreaterThan(cpIdx);
  });

  it('aborts at restart on restart failure', async () => {
    const { exec } = makeExec({
      "cat '": { code: 0, stdout: 'models:\n  old: {}\n', stderr: '' },
      'cp ': { code: 0, stdout: '', stderr: '' },
      'cat >': { code: 0, stdout: '', stderr: '' },
      restart: { code: 1, stdout: '', stderr: 'service not found' },
    });
    const r = await applyRemoteConfig({
      target, configPath: '/c.yaml', restartCmd: 'restart-svc', newConfig: VALID_YAML,
      schema: SCHEMA, baseUrl: 'http://h', exec, fetcher: okFetcher,
    });
    expect(r.ok).toBe(false);
    expect(r.step).toBe('restart');
  });

  it('aborts at health when the service never comes back', async () => {
    const { exec } = makeExec({
      "cat '": { code: 0, stdout: 'models:\n  old: {}\n', stderr: '' },
      'cp ': { code: 0, stdout: '', stderr: '' },
      'cat >': { code: 0, stdout: '', stderr: '' },
      'restart-svc': { code: 0, stdout: '', stderr: '' },
    });
    const downFetcher = (async () => { throw new Error('refused'); }) as unknown as typeof fetch;
    const r = await applyRemoteConfig({
      target, configPath: '/c.yaml', restartCmd: 'restart-svc', newConfig: VALID_YAML,
      schema: SCHEMA, baseUrl: 'http://h', exec, fetcher: downFetcher,
      healthAttempts: 2, healthDelayMs: 1,
    });
    expect(r.ok).toBe(false);
    expect(r.step).toBe('health');
  });

  it('succeeds through the full pipeline', async () => {
    const { exec } = makeExec({
      "cat '": { code: 0, stdout: 'models:\n  old: {}\n', stderr: '' },
      'cp ': { code: 0, stdout: '', stderr: '' },
      'cat >': { code: 0, stdout: '', stderr: '' },
      'restart-svc': { code: 0, stdout: '', stderr: '' },
    });
    const r = await applyRemoteConfig({
      target, configPath: '/c.yaml', restartCmd: 'restart-svc', newConfig: VALID_YAML,
      schema: SCHEMA, baseUrl: 'http://h', exec, fetcher: okFetcher,
      healthAttempts: 1, healthDelayMs: 1,
    });
    expect(r.ok).toBe(true);
    expect(r.step).toBe('done');
    expect(r.backupPath).toBeDefined();
  });
});

describe('healthWait', () => {
  it('returns true on first OK', async () => {
    const ok = await healthWait('http://h', okFetcher, 3, 1);
    expect(ok).toBe(true);
  });
  it('returns false after exhausting attempts', async () => {
    const downFetcher = (async () => new Response('', { status: 503 })) as unknown as typeof fetch;
    const ok = await healthWait('http://h', downFetcher, 2, 1);
    expect(ok).toBe(false);
  });
});

// ─── wrapper mode (forced-command verbs) ─────────────────────────────────────

describe('applyRemoteConfig wrapper mode', () => {
  it('sends verbs (not raw shell) and reads the backup path from the backup verb', async () => {
    const { exec, calls } = makeExec({
      read: { code: 0, stdout: 'models:\n  old: {}\n', stderr: '' },
      backup: { code: 0, stdout: '/c.yaml.bak-WRAP\n', stderr: '' },
      write: { code: 0, stdout: '', stderr: '' },
      restart: { code: 0, stdout: '', stderr: '' },
    });
    const r = await applyRemoteConfig({
      target, configPath: '/c.yaml', restartCmd: 'ignored-in-wrapper', newConfig: VALID_YAML,
      schema: SCHEMA, baseUrl: 'http://h', exec, fetcher: okFetcher, mode: 'wrapper',
      healthAttempts: 1, healthDelayMs: 1,
    });
    expect(r.ok).toBe(true);
    // backup path comes from the wrapper's stdout, not a client-computed name
    expect(r.backupPath).toBe('/c.yaml.bak-WRAP');
    // verbs only — no cat/cp/cat > shell commands
    expect(calls).toEqual(['read', 'backup', 'write', 'restart']);
    expect(calls.some((c) => c.includes('cat') || c.includes('cp '))).toBe(false);
  });

  it('aborts at write when the wrapper write verb fails (backup retained)', async () => {
    const { exec } = makeExec({
      read: { code: 0, stdout: 'old\n', stderr: '' },
      backup: { code: 0, stdout: '/c.yaml.bak-WRAP\n', stderr: '' },
      write: { code: 1, stdout: '', stderr: 'denied' },
    });
    const r = await applyRemoteConfig({
      target, configPath: '/c.yaml', restartCmd: 'x', newConfig: VALID_YAML,
      schema: SCHEMA, baseUrl: 'http://h', exec, fetcher: okFetcher, mode: 'wrapper',
    });
    expect(r.ok).toBe(false);
    expect(r.step).toBe('write');
    expect(r.backupPath).toBe('/c.yaml.bak-WRAP');
  });
});
