/**
 * Tests for the v1 → v2 config normalizer (Step 1 of the 2026-05-21
 * Settings UI / Config Restructure design).
 *
 * Coverage matrix:
 *  - explicit v2 input → pass through (no warning, no migration)
 *  - explicit `config_version: 99` → fatal
 *  - v1 provider.workers[].proxy = true → connection_type aao_gateway
 *  - v1 provider.workers[].proxy = false/missing → connection_type direct
 *  - provider.model fills missing worker.model
 *  - both empty → empty model + warning (not fatal)
 *  - storage.* mirrors of worktree_dir / custom_pieces_dir / user_folder_root /
 *    tools.task_upload_max_size_mb / tools.trash_retention_days
 *  - `${VAR}` references preserved verbatim (not coerced into literals)
 *  - fixture-driven snapshots for the four documented production shapes
 */
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
import { parse as parseYaml } from 'yaml';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import {
  normalizeConfig,
  UnsupportedConfigVersionError,
} from './config-normalize.js';
import { toSnakeKeys } from './config.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const FIXTURES_DIR = join(__dirname, '__fixtures__', 'config-migration');

/**
 * Tiny helper: parse a YAML string and pre-camelCase keys the way loadConfig
 * does. The normalizer expects camelCased keys (post-transformKeys).
 */
function loadYaml(yamlText: string): unknown {
  return transformToCamel(parseYaml(yamlText));
}
function loadFixture(name: string): unknown {
  return loadYaml(readFileSync(join(FIXTURES_DIR, name), 'utf-8'));
}

/** snake_case → camelCase recursive (mirror of config.ts transformKeys). */
function transformToCamel(obj: unknown): unknown {
  if (Array.isArray(obj)) return obj.map(transformToCamel);
  if (obj !== null && typeof obj === 'object') {
    return Object.fromEntries(
      Object.entries(obj as Record<string, unknown>).map(([k, v]) => [
        k.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()),
        transformToCamel(v),
      ]),
    );
  }
  return obj;
}

describe('normalizeConfig — version handling', () => {
  it('v2 input passes through with config_version=2', () => {
    const out = normalizeConfig({
      configVersion: 2,
      llm: {
        workers: [
          {
            id: 'w1',
            connectionType: 'direct',
            endpoint: 'http://localhost:11434/v1',
            model: 'qwen3:32b',
            roles: ['auto'],
            maxConcurrency: 1,
            enabled: true,
          },
        ],
      },
    });
    expect(out.configVersion).toBe(2);
    expect(out.llm?.workers).toHaveLength(1);
    expect(out.llm?.workers[0]).toMatchObject({
      id: 'w1',
      connectionType: 'direct',
      model: 'qwen3:32b',
    });
  });

  it('missing config_version treated as v1', () => {
    const out = normalizeConfig({
      provider: {
        baseUrl: 'http://localhost:11434/v1',
        model: 'qwen3:32b',
        workers: [],
      },
    });
    expect(out.configVersion).toBe(2);
    // v1 → defaulted single worker from baseUrl
    expect(out.llm?.workers).toHaveLength(1);
    expect(out.llm?.workers[0]?.endpoint).toBe('http://localhost:11434/v1');
  });

  it('config_version: 99 throws UnsupportedConfigVersionError', () => {
    expect(() => normalizeConfig({ configVersion: 99 })).toThrowError(UnsupportedConfigVersionError);
  });

  it('config_version: "2" (string) throws (typo guard)', () => {
    expect(() => normalizeConfig({ configVersion: '2' })).toThrowError(UnsupportedConfigVersionError);
  });

  it('config_version: 0 throws', () => {
    expect(() => normalizeConfig({ configVersion: 0 })).toThrowError(UnsupportedConfigVersionError);
  });

  it('null / non-object input is treated as empty config', () => {
    const out = normalizeConfig(null);
    expect(out.configVersion).toBe(2);
    expect(out.llm?.workers).toEqual([]);
  });
});

describe('normalizeConfig — v1 provider → v2 llm', () => {
  it('proxy: true → connection_type: aao_gateway', () => {
    const out = normalizeConfig({
      provider: {
        model: 'qwen3:8b',
        workers: [
          {
            id: 'team-pool',
            endpoint: 'http://litellm:4000/v1',
            proxy: true,
            apiKey: 'tok-abc',
          },
        ],
      },
    });
    expect(out.llm?.workers[0]).toMatchObject({
      id: 'team-pool',
      connectionType: 'aao_gateway',
      endpoint: 'http://litellm:4000/v1',
      apiKey: 'tok-abc',
    });
  });

  it('proxy: false → connection_type: direct', () => {
    const out = normalizeConfig({
      provider: {
        model: 'qwen3:8b',
        workers: [
          { id: 'gpu1', endpoint: 'http://gpu1/v1', proxy: false },
        ],
      },
    });
    expect(out.llm?.workers[0]?.connectionType).toBe('direct');
  });

  it('proxy omitted → connection_type: direct', () => {
    const out = normalizeConfig({
      provider: {
        model: 'qwen3:8b',
        workers: [{ id: 'gpu1', endpoint: 'http://gpu1/v1' }],
      },
    });
    expect(out.llm?.workers[0]?.connectionType).toBe('direct');
  });

  it('worker.model empty + provider.model set → worker.model inherits', () => {
    const out = normalizeConfig({
      provider: {
        model: 'qwen3:32b',
        workers: [{ id: 'gpu1', endpoint: 'http://gpu1/v1' }],
      },
    });
    expect(out.llm?.workers[0]?.model).toBe('qwen3:32b');
  });

  it('worker.model explicit overrides provider.model', () => {
    const out = normalizeConfig({
      provider: {
        model: 'qwen3:32b',
        workers: [
          { id: 'gpu1', endpoint: 'http://gpu1/v1', model: 'qwen3:14b' },
        ],
      },
    });
    expect(out.llm?.workers[0]?.model).toBe('qwen3:14b');
  });

  it('worker.model empty + provider.model empty → empty string, no throw', () => {
    expect(() =>
      normalizeConfig({
        provider: { workers: [{ id: 'gpu1', endpoint: 'http://gpu1/v1' }] },
      }),
    ).not.toThrow();
    const out = normalizeConfig({
      provider: { workers: [{ id: 'gpu1', endpoint: 'http://gpu1/v1' }] },
    });
    expect(out.llm?.workers[0]?.model).toBe('');
  });

  it('base_url with no workers → single default worker', () => {
    const out = normalizeConfig({
      provider: {
        baseUrl: 'http://localhost:11434/v1',
        model: 'qwen3:32b',
        workers: [],
      },
    });
    expect(out.llm?.workers).toHaveLength(1);
    expect(out.llm?.workers[0]).toMatchObject({
      id: 'default',
      endpoint: 'http://localhost:11434/v1',
      model: 'qwen3:32b',
      connectionType: 'direct',
    });
  });

  it('provider.timeout_minutes / retry / metrics → llm.*', () => {
    const out = normalizeConfig({
      provider: {
        model: 'qwen3:8b',
        baseUrl: 'http://localhost:11434/v1',
        timeoutMinutes: 20,
        retry: {
          maxAttempts: 5,
          backoffMs: [1000, 2000],
          retryableStatus: [429, 503],
        },
        metrics: { enabled: true, prefix: 'aao_worker' },
      },
    });
    expect(out.llm?.timeoutMinutes).toBe(20);
    expect(out.llm?.retry).toEqual({
      maxAttempts: 5,
      backoffMs: [1000, 2000],
      retryableStatus: [429, 503],
    });
    expect(out.llm?.metrics).toEqual({ enabled: true, prefix: 'aao_worker' });
  });

  it('profiles: [...] → roles: [...] on v2 worker', () => {
    const out = normalizeConfig({
      provider: {
        model: 'm',
        workers: [
          // Use camelCased keys (post-transformKeys)
          { id: 'g', endpoint: 'http://g/v1', profiles: ['fast'] },
        ],
      },
    });
    expect(out.llm?.workers[0]?.roles).toEqual(['fast']);
  });

  it('roles defaults to [auto, fast, quality] when neither roles nor profiles set', () => {
    const out = normalizeConfig({
      provider: {
        model: 'm',
        workers: [{ id: 'g', endpoint: 'http://g/v1' }],
      },
    });
    expect(out.llm?.workers[0]?.roles).toEqual(['auto', 'fast', 'quality']);
  });
});

describe('normalizeConfig — storage migration', () => {
  it('top-level worktree_dir → storage.worktreeDir', () => {
    const out = normalizeConfig({
      worktreeDir: '/data/workspaces',
      provider: { workers: [{ id: 'g', endpoint: 'http://g/v1', model: 'm' }] },
    });
    expect(out.storage?.worktreeDir).toBe('/data/workspaces');
  });

  it('tools.task_upload_max_size_mb → storage.taskUploadMaxSizeMb', () => {
    const out = normalizeConfig({
      tools: { taskUploadMaxSizeMb: 100 },
      provider: { workers: [{ id: 'g', endpoint: 'http://g/v1', model: 'm' }] },
    });
    expect(out.storage?.taskUploadMaxSizeMb).toBe(100);
  });

  it('tools.trash_retention_days → storage.trashRetentionDays', () => {
    const out = normalizeConfig({
      tools: { trashRetentionDays: 7 },
      provider: { workers: [{ id: 'g', endpoint: 'http://g/v1', model: 'm' }] },
    });
    expect(out.storage?.trashRetentionDays).toBe(7);
  });

  it('all storage keys round-trip together', () => {
    const out = normalizeConfig({
      worktreeDir: '/w',
      customPiecesDir: './cp',
      userFolderRoot: './users',
      tools: { taskUploadMaxSizeMb: 50, trashRetentionDays: 30 },
      provider: { workers: [{ id: 'g', endpoint: 'http://g/v1', model: 'm' }] },
    });
    expect(out.storage).toEqual({
      worktreeDir: '/w',
      customPiecesDir: './cp',
      userFolderRoot: './users',
      taskUploadMaxSizeMb: 50,
      trashRetentionDays: 30,
    });
  });

  it('existing storage.* wins over legacy flat keys', () => {
    const out = normalizeConfig({
      worktreeDir: '/old',
      storage: { worktreeDir: '/new' },
      provider: { workers: [{ id: 'g', endpoint: 'http://g/v1', model: 'm' }] },
    });
    expect(out.storage?.worktreeDir).toBe('/new');
  });
});

describe('normalizeConfig — env reference preservation', () => {
  it('${VAR} in worker api_key is preserved verbatim', () => {
    const out = normalizeConfig({
      provider: {
        model: 'm',
        workers: [
          {
            id: 'team',
            endpoint: 'http://t/v1',
            proxy: true,
            apiKey: '${TEAM_KEY}',
          },
        ],
      },
    });
    expect(out.llm?.workers[0]?.apiKey).toBe('${TEAM_KEY}');
  });

  it('${VAR} in provider.metrics.bearer_token is preserved', () => {
    const out = normalizeConfig({
      provider: {
        model: 'm',
        baseUrl: 'http://x/v1',
        metrics: { bearerToken: '${BEARER}' },
      },
    });
    expect(out.llm?.metrics?.bearerToken).toBe('${BEARER}');
  });

  it('env: prefix is preserved (legacy syntax)', () => {
    const out = normalizeConfig({
      provider: {
        model: 'm',
        baseUrl: 'http://x/v1',
        metrics: { bearerToken: 'env:LEGACY_BEARER' },
      },
    });
    expect(out.llm?.metrics?.bearerToken).toBe('env:LEGACY_BEARER');
  });
});

describe('normalizeConfig — fixtures', () => {
  it('v1-single-ollama.yaml normalizes to expected v2 shape', () => {
    const raw = loadFixture('v1-single-ollama.yaml');
    const out = normalizeConfig(raw);

    expect(out.configVersion).toBe(2);
    expect(out.llm?.workers).toHaveLength(1);
    expect(out.llm?.workers[0]).toMatchObject({
      id: 'default',
      connectionType: 'direct',
      endpoint: 'http://localhost:11434/v1',
      model: 'qwen3:32b',
      enabled: true,
      maxConcurrency: 1,
    });
    expect(out.llm?.timeoutMinutes).toBe(10);
    expect(out.llm?.retry).toEqual({
      maxAttempts: 3,
      backoffMs: [2000, 5000, 15000],
      retryableStatus: [429, 500, 502, 503, 504],
    });
    expect(out.storage).toEqual({
      worktreeDir: '/var/lib/agent-orchestrator/workspaces',
      customPiecesDir: './custom-pieces',
      userFolderRoot: './data/users',
      taskUploadMaxSizeMb: 50,
      trashRetentionDays: 30,
    });
  });

  it('v1-multi-worker-with-proxy.yaml maps proxy:true to aao_gateway + keeps ${} refs', () => {
    const raw = loadFixture('v1-multi-worker-with-proxy.yaml');
    const out = normalizeConfig(raw);

    expect(out.configVersion).toBe(2);
    expect(out.llm?.timeoutMinutes).toBe(15);
    expect(out.llm?.workers).toHaveLength(4);

    const byId = new Map(out.llm!.workers.map(w => [w.id, w]));
    expect(byId.get('gpu1')).toMatchObject({
      connectionType: 'direct',
      model: 'qwen3:32b', // inherits from provider.model
      roles: ['auto', 'fast'],
      maxConcurrency: 2,
    });
    expect(byId.get('gpu2')).toMatchObject({
      connectionType: 'direct',
      model: 'qwen3:14b', // worker override
      vlm: true,
    });
    expect(byId.get('team-pool')).toMatchObject({
      connectionType: 'aao_gateway',
      apiKey: '${TEAM_A_LITELLM_KEY}', // env ref preserved literally
      model: 'qwen3:8b',
      maxConcurrency: 4,
      roles: ['quality'],
    });
    expect(byId.get('gpu-reflection')).toMatchObject({
      connectionType: 'direct',
      roles: ['reflection'],
    });
  });

  it('v1-gateway-server-with-keys.yaml preserves gateway.* untouched + builds llm from provider', () => {
    const raw = loadFixture('v1-gateway-server-with-keys.yaml');
    const out = normalizeConfig(raw);

    expect(out.configVersion).toBe(2);
    // provider → llm path
    expect(out.llm?.workers).toHaveLength(1);
    expect(out.llm?.workers[0]).toMatchObject({
      id: 'default',
      endpoint: 'http://localhost:11434/v1',
      model: 'qwen3:8b',
      connectionType: 'direct',
    });
    expect(out.llm?.metrics?.bearerToken).toBe('${AAO_WORKER_METRICS_BEARER_TOKEN}');

    // gateway.* should pass through untouched.
    const gateway = (out as unknown as { gateway?: Record<string, unknown> }).gateway;
    expect(gateway).toBeDefined();
    expect(gateway).toMatchObject({
      enabled: true,
      listenPort: 4000,
    });
    expect(Array.isArray(gateway?.backends)).toBe(true);
    expect((gateway?.backends as unknown[])).toHaveLength(2);
    const vkeys = gateway?.virtualKeys as Array<Record<string, unknown>>;
    expect(vkeys[0]?.key).toBe('${TEAM_A_GATEWAY_KEY}'); // env ref kept
  });

  it('v1-mcp-and-ssh.yaml mirrors user_folder_root into storage + preserves mcp/ssh', () => {
    const raw = loadFixture('v1-mcp-and-ssh.yaml');
    const out = normalizeConfig(raw);

    expect(out.configVersion).toBe(2);
    expect(out.storage?.userFolderRoot).toBe('/opt/aao/data/users');
    expect(out.storage?.taskUploadMaxSizeMb).toBe(100);

    // mcp + ssh blocks should pass through unchanged
    expect(out.mcp).toMatchObject({ enabled: true, callTimeoutSeconds: 30 });
    expect(out.ssh).toMatchObject({
      enabled: true,
      masterKeyPath: './data/secrets/ssh-master.key',
    });

    // ${VAR} inside tools.knowledge_namespaces stays literal
    const tools = (out as unknown as { tools?: { knowledgeNamespaces?: Record<string, { apiKey: string }> } }).tools;
    expect(tools?.knowledgeNamespaces?.eng?.apiKey).toBe('${DKS_ENG_KEY}');
    expect(tools?.knowledgeNamespaces?.ops?.apiKey).toBe('${DKS_OPS_KEY}');
  });
});

describe('normalizeConfig — backwards compat with loadConfig', () => {
  it('toSnakeKeys round-trip on normalizer output stays consumable by YAML stringifier', () => {
    // Sanity guard: the v2 blocks survive snake_case conversion (used by
    // /api/config write path in later steps).
    const out = normalizeConfig({
      provider: {
        model: 'qwen3:8b',
        workers: [
          { id: 'gpu1', endpoint: 'http://gpu1/v1', proxy: true, apiKey: '${K}' },
        ],
      },
    });
    const snake = toSnakeKeys({
      config_version: out.configVersion,
      llm: out.llm,
      storage: out.storage,
    }) as Record<string, unknown>;
    expect((snake.llm as Record<string, unknown>).workers).toBeDefined();
    const workers = (snake.llm as Record<string, unknown>).workers as Array<Record<string, unknown>>;
    expect(workers[0]).toMatchObject({
      id: 'gpu1',
      connection_type: 'aao_gateway',
      api_key: '${K}',
    });
  });

  it('mirrors storage.* back into top-level flat keys for legacy readers (2026-05-21 hotfix)', () => {
    // Production aao broke when v2-only config.yaml dropped top-level
    // `worktree_dir`. worker-bootstrap.ts:153/172 reads
    // `config.worktreeDir` and got the default `/var/lib/...` path,
    // which isn't writable on the typical deploy → EACCES on mkdir.
    // The normalizer now mirrors storage.* into the legacy top-level
    // keys so the compat-window readers keep working.
    const out = normalizeConfig({
      configVersion: 2,
      llm: { workers: [{ id: 'w1', connectionType: 'direct', endpoint: 'http://x/v1', model: 'm' }] },
      storage: {
        worktreeDir: '/home/user/data/agent-workspaces',
        customPiecesDir: '/home/user/data/pieces',
        userFolderRoot: '/home/user/data/users',
        taskUploadMaxSizeMb: 100,
        trashRetentionDays: 45,
      },
    });
    expect(out.worktreeDir).toBe('/home/user/data/agent-workspaces');
    expect(out.customPiecesDir).toBe('/home/user/data/pieces');
    expect(out.userFolderRoot).toBe('/home/user/data/users');
    expect(out.tools?.taskUploadMaxSizeMb).toBe(100);
    expect(out.tools?.trashRetentionDays).toBe(45);
  });

  it('storage.* explicit value wins over top-level value (#369 precedence fix)', () => {
    // After hotfix #369: when the source v2 input has an explicit
    // `storage.worktreeDir`, that value is authoritative and overrides
    // any pre-existing top-level value — because the top-level is
    // almost always the legacy default (`/var/lib/...`) merged in by
    // loadConfig before the normalizer runs. The env override (#369)
    // is re-applied AFTER normalizeConfig in loadConfig so a runtime
    // WORKTREE_DIR still wins; that contract isn't tested here.
    const out = normalizeConfig({
      configVersion: 2,
      llm: { workers: [{ id: 'w1', connectionType: 'direct', endpoint: 'http://x/v1', model: 'm' }] },
      worktreeDir: '/var/lib/maestro/workspaces', // simulates merged-in default
      storage: { worktreeDir: '/home/user/data/agent-workspaces' },
    });
    expect(out.worktreeDir).toBe('/home/user/data/agent-workspaces');
  });

  it('v1 top-level worktreeDir survives when no storage block was authored', () => {
    // Legacy path: a v1 file with `worktree_dir` but no `storage` block
    // synthesizes storage.worktreeDir from the top-level. The mirror is
    // a no-op (top-level was already set) and the user's value is
    // preserved on both sides.
    const out = normalizeConfig({
      // configVersion omitted → v1 path
      provider: { workers: [{ id: 'w1', endpoint: 'http://x/v1', model: 'm' }] },
      worktreeDir: '/home/op/explicit-v1-value',
    });
    expect(out.worktreeDir).toBe('/home/op/explicit-v1-value');
    expect(out.storage?.worktreeDir).toBe('/home/op/explicit-v1-value');
  });
});
