import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os, { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import {
  readAliasMap,
  readMaskedConfig,
  resolveModelAlias,
  resolveProviderConfig,
  seedProviderIfMissing,
  writeConfig,
} from '../src/media-config.js';

const TEST_NANOBANANA_BASE_URL = 'https://nano-banana-gateway.example.test';

const OPENAI_ENV_KEYS = [
  'OD_OPENAI_API_KEY',
  'OPENAI_API_KEY',
  'AZURE_API_KEY',
  'AZURE_OPENAI_API_KEY',
];

describe('media-config OpenAI auth-file fallback', () => {
  let homeDir: string;
  let projectRoot: string;
  const originalHome = process.env.HOME;
  const originalEnv = Object.fromEntries(
    OPENAI_ENV_KEYS.map((key) => [key, process.env[key]]),
  );
  const originalMediaConfigDir = process.env.OD_MEDIA_CONFIG_DIR;
  const originalDataDir = process.env.OD_DATA_DIR;
  const originalSandboxMode = process.env.OD_SANDBOX_MODE;
  let homedirSpy: ReturnType<typeof vi.spyOn>;

  beforeEach(async () => {
    homeDir = await mkdtemp(path.join(tmpdir(), 'od-media-home-'));
    projectRoot = await mkdtemp(path.join(tmpdir(), 'od-media-project-'));
    process.env.HOME = homeDir;
    homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(homeDir);
    for (const key of OPENAI_ENV_KEYS) {
      delete process.env[key];
    }
    delete process.env.OD_MEDIA_CONFIG_DIR;
    delete process.env.OD_DATA_DIR;
    delete process.env.OD_SANDBOX_MODE;
  });

  afterEach(async () => {
    if (originalHome == null) {
      delete process.env.HOME;
    } else {
      process.env.HOME = originalHome;
    }
    for (const key of OPENAI_ENV_KEYS) {
      if (originalEnv[key] == null) {
        delete process.env[key];
      } else {
        process.env[key] = originalEnv[key];
      }
    }
    if (originalMediaConfigDir == null) {
      delete process.env.OD_MEDIA_CONFIG_DIR;
    } else {
      process.env.OD_MEDIA_CONFIG_DIR = originalMediaConfigDir;
    }
    if (originalDataDir == null) {
      delete process.env.OD_DATA_DIR;
    } else {
      process.env.OD_DATA_DIR = originalDataDir;
    }
    if (originalSandboxMode == null) {
      delete process.env.OD_SANDBOX_MODE;
    } else {
      process.env.OD_SANDBOX_MODE = originalSandboxMode;
    }
    homedirSpy.mockRestore();
    await rm(homeDir, { recursive: true, force: true });
    await rm(projectRoot, { recursive: true, force: true });
  });

  async function writeHomeJson(relPath: string, data: unknown) {
    const file = path.join(homeDir, relPath);
    await mkdir(path.dirname(file), { recursive: true });
    await writeFile(file, JSON.stringify(data), 'utf8');
  }

  async function writeStoredMediaConfig(data: unknown) {
    const file = path.join(projectRoot, '.od', 'media-config.json');
    await mkdir(path.dirname(file), { recursive: true });
    await writeFile(file, JSON.stringify(data), 'utf8');
  }

  function openaiProvider(masked: { providers: unknown }) {
    return (masked.providers as Record<string, unknown>).openai;
  }

  it('ignores Hermes openai-codex OAuth for media generation', async () => {
    await writeHomeJson('.hermes/auth.json', {
      providers: {
        'openai-codex': {
          tokens: { access_token: 'hermes-oauth-token' },
        },
      },
    });

    const resolved = await resolveProviderConfig(projectRoot, 'openai');
    const masked = await readMaskedConfig(projectRoot);

    expect(resolved.apiKey).toBe('');
    expect(openaiProvider(masked)).toMatchObject({
      configured: false,
      source: 'unset',
      apiKeyTail: '',
    });
  });

  it('ignores Codex OAuth tokens for media generation', async () => {
    await writeHomeJson('.codex/auth.json', {
      tokens: { access_token: 'codex-oauth-token' },
    });

    const resolved = await resolveProviderConfig(projectRoot, 'openai');
    const masked = await readMaskedConfig(projectRoot);

    expect(resolved.apiKey).toBe('');
    expect(openaiProvider(masked)).toMatchObject({
      configured: false,
      source: 'unset',
      apiKeyTail: '',
    });
  });

  it('does not read host OpenAI auth files in sandbox mode', async () => {
    process.env.OD_SANDBOX_MODE = '1';
    await writeHomeJson('.hermes/auth.json', {
      providers: {
        'openai-codex': {
          tokens: { access_token: 'hermes-oauth-token' },
        },
      },
    });
    await writeHomeJson('.codex/auth.json', {
      tokens: { access_token: 'codex-oauth-token' },
      OPENAI_API_KEY: 'host-codex-api-key',
    });

    const resolved = await resolveProviderConfig(projectRoot, 'openai');
    const masked = await readMaskedConfig(projectRoot);

    expect(resolved.apiKey).toBe('');
    expect(openaiProvider(masked)).toMatchObject({
      configured: false,
      source: 'unset',
    });
  });

  it('uses explicit OPENAI_API_KEY from Codex auth files', async () => {
    await writeHomeJson('.codex/auth.json', {
      tokens: { access_token: 'codex-oauth-token' },
      OPENAI_API_KEY: 'codex-api-key',
    });

    const resolved = await resolveProviderConfig(projectRoot, 'openai');
    const masked = await readMaskedConfig(projectRoot);

    expect(resolved.apiKey).toBe('codex-api-key');
    expect(openaiProvider(masked)).toMatchObject({
      configured: true,
      source: 'codex-auth',
      apiKeyTail: '',
    });
  });

  it('keeps stored provider config ahead of auth-file fallbacks', async () => {
    await writeHomeJson('.hermes/auth.json', {
      providers: {
        'openai-codex': {
          tokens: { access_token: 'hermes-oauth-token' },
        },
      },
    });
    await writeStoredMediaConfig({
      providers: {
        openai: {
          apiKey: 'stored-openai-key',
          baseUrl: 'https://example.test/v1',
        },
      },
    });

    const resolved = await resolveProviderConfig(projectRoot, 'openai');
    const masked = await readMaskedConfig(projectRoot);

    expect(resolved).toEqual({
      apiKey: 'stored-openai-key',
      baseUrl: 'https://example.test/v1',
    });
    expect(openaiProvider(masked)).toMatchObject({
      configured: true,
      source: 'stored',
      apiKeyTail: '-key',
      baseUrl: 'https://example.test/v1',
    });
  });

  it('resolves Nano Banana env and stored model overrides', async () => {
    process.env.OD_NANOBANANA_API_KEY = 'env-nano-key';
    await writeStoredMediaConfig({
      providers: {
        nanobanana: {
          apiKey: 'stored-nano-key',
          baseUrl: TEST_NANOBANANA_BASE_URL,
          model: 'gemini-3.1-flash-image-preview-custom',
        },
      },
    });

    const resolved = await resolveProviderConfig(projectRoot, 'nanobanana');
    const masked = await readMaskedConfig(projectRoot);
    const provider = (masked.providers as Record<string, unknown>).nanobanana;

    expect(resolved).toEqual({
      apiKey: 'env-nano-key',
      baseUrl: TEST_NANOBANANA_BASE_URL,
      model: 'gemini-3.1-flash-image-preview-custom',
    });
    expect(provider).toMatchObject({
      configured: true,
      source: 'env',
      apiKeyTail: '-key',
      baseUrl: TEST_NANOBANANA_BASE_URL,
      model: 'gemini-3.1-flash-image-preview-custom',
    });

    delete process.env.OD_NANOBANANA_API_KEY;
  });

  it('preserves a stored apiKey when writeConfig updates only non-secret fields', async () => {
    await writeStoredMediaConfig({
      providers: {
        openai: {
          apiKey: 'stored-openai-key',
          baseUrl: 'https://before.example/v1',
        },
      },
    });

    await writeConfig(projectRoot, {
      providers: {
        openai: {
          preserveApiKey: true,
          baseUrl: 'https://after.example/v1',
        },
      },
      force: true,
    });

    await expect(resolveProviderConfig(projectRoot, 'openai')).resolves.toEqual({
      apiKey: 'stored-openai-key',
      baseUrl: 'https://after.example/v1',
    });
  });

  describe('OD_MEDIA_CONFIG_DIR / OD_DATA_DIR storage routing', () => {
    let overrideRoot: string;
    let originalMediaConfigDir: string | undefined;
    let originalDataDir: string | undefined;

    beforeEach(async () => {
      overrideRoot = await mkdtemp(path.join(tmpdir(), 'od-media-override-'));
      originalMediaConfigDir = process.env.OD_MEDIA_CONFIG_DIR;
      originalDataDir = process.env.OD_DATA_DIR;
      delete process.env.OD_MEDIA_CONFIG_DIR;
      delete process.env.OD_DATA_DIR;
    });

    afterEach(async () => {
      if (originalMediaConfigDir == null) {
        delete process.env.OD_MEDIA_CONFIG_DIR;
      } else {
        process.env.OD_MEDIA_CONFIG_DIR = originalMediaConfigDir;
      }
      if (originalDataDir == null) {
        delete process.env.OD_DATA_DIR;
      } else {
        process.env.OD_DATA_DIR = originalDataDir;
      }
      await rm(overrideRoot, { recursive: true, force: true });
    });

    async function writeProvidersAt(dir: string, data: unknown) {
      await mkdir(dir, { recursive: true });
      await writeFile(
        path.join(dir, 'media-config.json'),
        JSON.stringify(data),
        'utf8',
      );
    }

    it('reads media-config.json from an absolute OD_MEDIA_CONFIG_DIR', async () => {
      process.env.OD_MEDIA_CONFIG_DIR = overrideRoot;
      await writeProvidersAt(overrideRoot, {
        providers: {
          openai: {
            apiKey: 'absolute-key',
            baseUrl: 'https://absolute.test/v1',
          },
        },
      });

      const resolved = await resolveProviderConfig(projectRoot, 'openai');
      expect(resolved).toEqual({
        apiKey: 'absolute-key',
        baseUrl: 'https://absolute.test/v1',
      });
    });

    it('expands a leading ~/ against the user home directory', async () => {
      // Per-test HOME points at a tmpdir (set by outer beforeEach), so the
      // expansion lands somewhere safe to write.
      const subdir = '.od-test';
      process.env.OD_MEDIA_CONFIG_DIR = `~/${subdir}`;
      const expandedDir = path.join(homeDir, subdir);
      await writeProvidersAt(expandedDir, {
        providers: {
          openai: {
            apiKey: 'tilde-key',
            baseUrl: 'https://tilde.test/v1',
          },
        },
      });

      const resolved = await resolveProviderConfig(projectRoot, 'openai');
      expect(resolved).toEqual({
        apiKey: 'tilde-key',
        baseUrl: 'https://tilde.test/v1',
      });
    });

    it('resolves a relative override against projectRoot, not process.cwd', async () => {
      // process.cwd() during tests is typically the workspace root, which
      // is unrelated to the per-test projectRoot. A relative override must
      // land inside projectRoot, mirroring how resolveDataDir() in
      // server.ts anchors OD_DATA_DIR.
      const relative = 'config/media';
      process.env.OD_MEDIA_CONFIG_DIR = relative;
      const anchoredDir = path.join(projectRoot, relative);
      await writeProvidersAt(anchoredDir, {
        providers: {
          openai: {
            apiKey: 'relative-key',
            baseUrl: 'https://relative.test/v1',
          },
        },
      });

      const resolved = await resolveProviderConfig(projectRoot, 'openai');
      expect(resolved).toEqual({
        apiKey: 'relative-key',
        baseUrl: 'https://relative.test/v1',
      });
    });

    it('falls back to OD_DATA_DIR when OD_MEDIA_CONFIG_DIR is unset', async () => {
      // Packaged daemon (apps/packaged/src/sidecars.ts) and the
      // Home Manager / NixOS modules already set OD_DATA_DIR for the
      // rest of the daemon's runtime state. media-config should
      // co-locate there without needing a second env var.
      process.env.OD_DATA_DIR = overrideRoot;
      await writeProvidersAt(overrideRoot, {
        providers: {
          openai: {
            apiKey: 'datadir-key',
            baseUrl: 'https://datadir.test/v1',
          },
        },
      });

      const resolved = await resolveProviderConfig(projectRoot, 'openai');
      expect(resolved).toEqual({
        apiKey: 'datadir-key',
        baseUrl: 'https://datadir.test/v1',
      });
    });

    it('OD_MEDIA_CONFIG_DIR takes precedence over OD_DATA_DIR', async () => {
      const dataDir = await mkdtemp(path.join(tmpdir(), 'od-media-data-'));
      try {
        process.env.OD_DATA_DIR = dataDir;
        process.env.OD_MEDIA_CONFIG_DIR = overrideRoot;
        // Two competing files; only the OD_MEDIA_CONFIG_DIR one should
        // be read.
        await writeProvidersAt(dataDir, {
          providers: {
            openai: { apiKey: 'data-key', baseUrl: 'https://data/v1' },
          },
        });
        await writeProvidersAt(overrideRoot, {
          providers: {
            openai: { apiKey: 'media-key', baseUrl: 'https://media/v1' },
          },
        });

        const resolved = await resolveProviderConfig(projectRoot, 'openai');
        expect(resolved).toEqual({
          apiKey: 'media-key',
          baseUrl: 'https://media/v1',
        });
      } finally {
        await rm(dataDir, { recursive: true, force: true });
      }
    });

    it('writeConfig creates the override directory tree on first write', async () => {
      // Reproduces the actual user-reported failure mode: the override
      // directory does not exist yet (first launch on a read-only
      // install root), so writeConfig must mkdir -p before writing.
      // Without recursive mkdir + a writable override, this would
      // surface as ENOENT/EROFS to PUT /api/media/config.
      const target = path.join(overrideRoot, 'nested', 'inner');
      process.env.OD_MEDIA_CONFIG_DIR = target;

      await writeConfig(projectRoot, {
        providers: {
          openai: {
            apiKey: 'fresh-write-key',
            baseUrl: 'https://fresh.test/v1',
          },
        },
      });

      // File materialised at the override path.
      const onDisk = await readFile(
        path.join(target, 'media-config.json'),
        'utf8',
      );
      expect(JSON.parse(onDisk)).toEqual({
        providers: {
          openai: {
            apiKey: 'fresh-write-key',
            baseUrl: 'https://fresh.test/v1',
          },
        },
      });

      // And resolveProviderConfig reads it back correctly.
      const resolved = await resolveProviderConfig(projectRoot, 'openai');
      expect(resolved).toEqual({
        apiKey: 'fresh-write-key',
        baseUrl: 'https://fresh.test/v1',
      });
    });

    // Round 3 review feedback on PR #530.
    // resolveOverrideDir shares expandHomePrefix with resolveDataDir, so
    // OD_DATA_DIR=$HOME/.open-design (and ${HOME}/.open-design) routes
    // both daemon runtime data AND media credentials to the same expanded
    // path. Without this, media-config.json was written under
    // <projectRoot>/$HOME/.open-design and stored provider keys appeared
    // missing on the next read.
    it('expands $HOME/... in OD_DATA_DIR fallback so media-config co-locates with daemon data', async () => {
      const subdir = '.od-test-home';
      process.env.OD_DATA_DIR = `$HOME/${subdir}`;
      const expandedDir = path.join(homeDir, subdir);
      await writeProvidersAt(expandedDir, {
        providers: {
          openai: {
            apiKey: 'home-key',
            baseUrl: 'https://home.test/v1',
          },
        },
      });

      const resolved = await resolveProviderConfig(projectRoot, 'openai');
      expect(resolved).toEqual({
        apiKey: 'home-key',
        baseUrl: 'https://home.test/v1',
      });
    });

    it('expands ${HOME}/... in OD_DATA_DIR fallback', async () => {
      const subdir = '.od-test-braced';
      process.env.OD_DATA_DIR = `\${HOME}/${subdir}`;
      const expandedDir = path.join(homeDir, subdir);
      await writeProvidersAt(expandedDir, {
        providers: {
          openai: {
            apiKey: 'braced-key',
            baseUrl: 'https://braced.test/v1',
          },
        },
      });

      const resolved = await resolveProviderConfig(projectRoot, 'openai');
      expect(resolved).toEqual({
        apiKey: 'braced-key',
        baseUrl: 'https://braced.test/v1',
      });
    });

    it('expands $HOME/... in OD_MEDIA_CONFIG_DIR (explicit override path)', async () => {
      const subdir = '.od-media-home';
      process.env.OD_MEDIA_CONFIG_DIR = `$HOME/${subdir}`;
      const expandedDir = path.join(homeDir, subdir);
      await writeProvidersAt(expandedDir, {
        providers: {
          openai: {
            apiKey: 'media-home-key',
            baseUrl: 'https://media-home.test/v1',
          },
        },
      });

      const resolved = await resolveProviderConfig(projectRoot, 'openai');
      expect(resolved).toEqual({
        apiKey: 'media-home-key',
        baseUrl: 'https://media-home.test/v1',
      });
    });
  });
});

const GROK_ENV_KEYS = ['OD_GROK_API_KEY', 'XAI_API_KEY'];

describe('media-config Grok / xAI OAuth fallback', () => {
  let homeDir: string;
  let projectRoot: string;
  const originalHome = process.env.HOME;
  const originalEnv = Object.fromEntries(
    GROK_ENV_KEYS.map((key) => [key, process.env[key]]),
  );
  const originalMediaConfigDir = process.env.OD_MEDIA_CONFIG_DIR;
  const originalDataDir = process.env.OD_DATA_DIR;
  let homedirSpy: ReturnType<typeof vi.spyOn>;

  beforeEach(async () => {
    homeDir = await mkdtemp(path.join(tmpdir(), 'od-media-grok-home-'));
    projectRoot = await mkdtemp(path.join(tmpdir(), 'od-media-grok-project-'));
    process.env.HOME = homeDir;
    homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(homeDir);
    for (const key of GROK_ENV_KEYS) {
      delete process.env[key];
    }
    delete process.env.OD_MEDIA_CONFIG_DIR;
    delete process.env.OD_DATA_DIR;
  });

  afterEach(async () => {
    if (originalHome == null) {
      delete process.env.HOME;
    } else {
      process.env.HOME = originalHome;
    }
    for (const key of GROK_ENV_KEYS) {
      if (originalEnv[key] == null) {
        delete process.env[key];
      } else {
        process.env[key] = originalEnv[key];
      }
    }
    if (originalMediaConfigDir == null) {
      delete process.env.OD_MEDIA_CONFIG_DIR;
    } else {
      process.env.OD_MEDIA_CONFIG_DIR = originalMediaConfigDir;
    }
    if (originalDataDir == null) {
      delete process.env.OD_DATA_DIR;
    } else {
      process.env.OD_DATA_DIR = originalDataDir;
    }
    homedirSpy.mockRestore();
    await rm(homeDir, { recursive: true, force: true });
    await rm(projectRoot, { recursive: true, force: true });
  });

  async function writeHomeJson(relPath: string, data: unknown) {
    const file = path.join(homeDir, relPath);
    await mkdir(path.dirname(file), { recursive: true });
    await writeFile(file, JSON.stringify(data), 'utf8');
  }

  async function writeOdXaiTokens(token: {
    accessToken: string;
    refreshToken?: string;
    expiresAt?: number;
  }) {
    const file = path.join(projectRoot, '.od', 'xai-tokens.json');
    await mkdir(path.dirname(file), { recursive: true });
    await writeFile(
      file,
      JSON.stringify({
        token: {
          accessToken: token.accessToken,
          tokenType: 'Bearer',
          savedAt: Date.now(),
          ...(token.refreshToken ? { refreshToken: token.refreshToken } : {}),
          ...(token.expiresAt !== undefined
            ? { expiresAt: token.expiresAt }
            : {}),
        },
      }),
      'utf8',
    );
  }

  async function writeStoredMediaConfig(data: unknown) {
    const file = path.join(projectRoot, '.od', 'media-config.json');
    await mkdir(path.dirname(file), { recursive: true });
    await writeFile(file, JSON.stringify(data), 'utf8');
  }

  function grokProvider(masked: { providers: unknown }) {
    return (masked.providers as Record<string, unknown>).grok;
  }

  it('uses OD-native xai-tokens.json when one is stored', async () => {
    await writeOdXaiTokens({
      accessToken: 'od-bearer-1',
      expiresAt: Date.now() + 3_600_000,
    });

    const resolved = await resolveProviderConfig(projectRoot, 'grok');
    const masked = await readMaskedConfig(projectRoot);

    expect(resolved.apiKey).toBe('od-bearer-1');
    expect(grokProvider(masked)).toMatchObject({
      configured: true,
      source: 'oauth-xai-stored',
      apiKeyTail: '',
    });
  });

  it('borrows the Hermes-side xai-oauth token when OD has no native creds', async () => {
    await writeHomeJson('.hermes/auth.json', {
      providers: {
        'xai-oauth': {
          tokens: { access_token: 'hermes-xai-bearer' },
        },
      },
    });

    const resolved = await resolveProviderConfig(projectRoot, 'grok');
    const masked = await readMaskedConfig(projectRoot);

    expect(resolved.apiKey).toBe('hermes-xai-bearer');
    expect(grokProvider(masked)).toMatchObject({
      configured: true,
      source: 'oauth-hermes-xai',
    });
  });

  it('prefers OD-native xai-tokens over Hermes borrowing', async () => {
    await writeOdXaiTokens({
      accessToken: 'od-bearer-2',
      expiresAt: Date.now() + 3_600_000,
    });
    await writeHomeJson('.hermes/auth.json', {
      providers: {
        'xai-oauth': {
          tokens: { access_token: 'hermes-xai-bearer' },
        },
      },
    });

    const resolved = await resolveProviderConfig(projectRoot, 'grok');
    expect(resolved.apiKey).toBe('od-bearer-2');
  });

  it('keeps env keys ahead of OAuth fallbacks', async () => {
    process.env.XAI_API_KEY = 'env-xai-key';
    await writeOdXaiTokens({
      accessToken: 'od-bearer-3',
      expiresAt: Date.now() + 3_600_000,
    });

    const resolved = await resolveProviderConfig(projectRoot, 'grok');
    const masked = await readMaskedConfig(projectRoot);

    expect(resolved.apiKey).toBe('env-xai-key');
    expect(grokProvider(masked)).toMatchObject({
      configured: true,
      source: 'env',
    });
  });

  it('keeps stored provider key ahead of OAuth fallbacks', async () => {
    await writeStoredMediaConfig({
      providers: {
        grok: { apiKey: 'stored-grok-key', baseUrl: 'https://api.x.ai/v1' },
      },
    });
    await writeOdXaiTokens({
      accessToken: 'od-bearer-4',
      expiresAt: Date.now() + 3_600_000,
    });

    const resolved = await resolveProviderConfig(projectRoot, 'grok');
    expect(resolved.apiKey).toBe('stored-grok-key');
  });

  it('returns empty when no env, no stored key, and no OAuth source exists', async () => {
    const resolved = await resolveProviderConfig(projectRoot, 'grok');
    const masked = await readMaskedConfig(projectRoot);

    expect(resolved.apiKey).toBe('');
    expect(grokProvider(masked)).toMatchObject({
      configured: false,
      source: 'unset',
    });
  });

  it('skips an OD-native token within the expiry skew when no refresh_token is stored', async () => {
    // expiresAt within the 120s skew window → treated as expired by
    // resolveXAIBearer. Without a refresh_token it can't recover, so
    // the resolver falls through to other sources (none here).
    await writeOdXaiTokens({
      accessToken: 'od-bearer-expired',
      expiresAt: Date.now() + 30_000,
    });

    const resolved = await resolveProviderConfig(projectRoot, 'grok');
    expect(resolved.apiKey).toBe('');
  });
});

describe('media-config model alias resolution (issue #1277)', () => {
  let projectRoot: string;
  const originalEnvAliases = process.env.OD_MEDIA_MODEL_ALIASES;
  const originalMediaConfigDir = process.env.OD_MEDIA_CONFIG_DIR;
  const originalDataDir = process.env.OD_DATA_DIR;

  beforeEach(async () => {
    projectRoot = await mkdtemp(path.join(tmpdir(), 'od-media-alias-'));
    delete process.env.OD_MEDIA_MODEL_ALIASES;
    delete process.env.OD_MEDIA_CONFIG_DIR;
    delete process.env.OD_DATA_DIR;
  });

  afterEach(async () => {
    if (originalEnvAliases == null) {
      delete process.env.OD_MEDIA_MODEL_ALIASES;
    } else {
      process.env.OD_MEDIA_MODEL_ALIASES = originalEnvAliases;
    }
    if (originalMediaConfigDir == null) {
      delete process.env.OD_MEDIA_CONFIG_DIR;
    } else {
      process.env.OD_MEDIA_CONFIG_DIR = originalMediaConfigDir;
    }
    if (originalDataDir == null) {
      delete process.env.OD_DATA_DIR;
    } else {
      process.env.OD_DATA_DIR = originalDataDir;
    }
    await rm(projectRoot, { recursive: true, force: true });
  });

  async function writeStoredMediaConfig(data: unknown) {
    const file = path.join(projectRoot, '.od', 'media-config.json');
    await mkdir(path.dirname(file), { recursive: true });
    await writeFile(file, JSON.stringify(data), 'utf8');
  }

  it('passes through unmapped model ids unchanged', async () => {
    expect(await resolveModelAlias(projectRoot, 'doubao-seedream-3-0-t2i-250415')).toBe(
      'doubao-seedream-3-0-t2i-250415',
    );
  });

  it('redirects via the stored aliases map in media-config.json', async () => {
    // The flagship use case from the issue: registered catalog id
    // -> the new model name the user actually has access to.
    await writeStoredMediaConfig({
      providers: {},
      aliases: { 'doubao-seedream-3-0-t2i-250415': 'doubao-seedream-5-0' },
    });
    expect(
      await resolveModelAlias(projectRoot, 'doubao-seedream-3-0-t2i-250415'),
    ).toBe('doubao-seedream-5-0');
  });

  it('redirects via the OD_MEDIA_MODEL_ALIASES env var', async () => {
    process.env.OD_MEDIA_MODEL_ALIASES = JSON.stringify({
      'doubao-seedream-3-0-t2i-250415': 'doubao-seedream-5-0',
    });
    expect(
      await resolveModelAlias(projectRoot, 'doubao-seedream-3-0-t2i-250415'),
    ).toBe('doubao-seedream-5-0');
  });

  it('lets the env var override an on-disk alias (env wins for power users)', async () => {
    await writeStoredMediaConfig({
      providers: {},
      aliases: { 'doubao-seedream-3-0-t2i-250415': 'on-disk-alias' },
    });
    process.env.OD_MEDIA_MODEL_ALIASES = JSON.stringify({
      'doubao-seedream-3-0-t2i-250415': 'env-alias',
    });
    expect(
      await resolveModelAlias(projectRoot, 'doubao-seedream-3-0-t2i-250415'),
    ).toBe('env-alias');
  });

  it('tolerates malformed env JSON and falls through to the stored map', async () => {
    // A user with a half-typed env var (`OD_MEDIA_MODEL_ALIASES='{'`)
    // should still get their on-disk aliases, not a hard error mid-
    // generation.
    process.env.OD_MEDIA_MODEL_ALIASES = '{not valid json';
    await writeStoredMediaConfig({
      providers: {},
      aliases: { 'doubao-seedream-3-0-t2i-250415': 'doubao-seedream-5-0' },
    });
    expect(
      await resolveModelAlias(projectRoot, 'doubao-seedream-3-0-t2i-250415'),
    ).toBe('doubao-seedream-5-0');
  });

  it('drops non-string and empty alias entries during coercion', async () => {
    // Defends against a future schema bump (number / null / nested
    // object) and against accidental empty-string entries from a
    // Settings UI form. The coercion must never feed garbage into a
    // dispatcher's request body.
    process.env.OD_MEDIA_MODEL_ALIASES = JSON.stringify({
      'good-key': 'good-value',
      'empty-key': '',
      'null-key': null,
      'object-key': { nested: 'no' },
      '': 'blank-key-rejected',
    });
    expect(await resolveModelAlias(projectRoot, 'good-key')).toBe('good-value');
    expect(await resolveModelAlias(projectRoot, 'empty-key')).toBe('empty-key');
    expect(await resolveModelAlias(projectRoot, 'null-key')).toBe('null-key');
    expect(await resolveModelAlias(projectRoot, 'object-key')).toBe('object-key');
  });

  it('exposes the merged map via readAliasMap so Settings can show source attribution', async () => {
    await writeStoredMediaConfig({
      providers: {},
      aliases: { 'stored-only': 'a', 'overridden': 'stored-value' },
    });
    process.env.OD_MEDIA_MODEL_ALIASES = JSON.stringify({
      'env-only': 'b',
      'overridden': 'env-value',
    });
    const map = await readAliasMap(projectRoot);
    expect(map.stored).toEqual({ 'stored-only': 'a', 'overridden': 'stored-value' });
    expect(map.env).toEqual({ 'env-only': 'b', 'overridden': 'env-value' });
    expect(map.effective).toEqual({
      'stored-only': 'a',
      'env-only': 'b',
      'overridden': 'env-value',
    });
  });

  it('readMaskedConfig surfaces the alias map for the Settings UI', async () => {
    // Lefarcen P3 (#1309 review): the prior PR description claimed
    // `readAliasMap` was the daemon-public API for the Settings UI,
    // but the HTTP route returned only `readMaskedConfig` (which
    // had no aliases field). The fix wires aliases into the GET
    // response so a future Settings UI PR can consume them without
    // touching the daemon.
    await writeStoredMediaConfig({
      providers: {},
      aliases: { 'dall-e-3': 'azure-dalle3' },
    });
    process.env.OD_MEDIA_MODEL_ALIASES = JSON.stringify({
      'gpt-4o-mini-tts': 'custom-tts',
    });

    const masked = await readMaskedConfig(projectRoot);

    expect(masked.aliases.stored).toEqual({ 'dall-e-3': 'azure-dalle3' });
    expect(masked.aliases.env).toEqual({ 'gpt-4o-mini-tts': 'custom-tts' });
    expect(masked.aliases.effective).toEqual({
      'dall-e-3': 'azure-dalle3',
      'gpt-4o-mini-tts': 'custom-tts',
    });
  });

  it('readMaskedConfig returns empty alias maps when no aliases are configured', async () => {
    // Settings UI needs a stable shape so it can render "no aliases
    // configured" without crashing on `aliases.effective` being
    // undefined.
    const masked = await readMaskedConfig(projectRoot);
    expect(masked.aliases.effective).toEqual({});
    expect(masked.aliases.env).toEqual({});
    expect(masked.aliases.stored).toEqual({});
  });

  it('writeConfig preserves aliases when a Settings-style provider PUT lands', async () => {
    // The Settings UI in its current shape writes providers only.
    // Without alias preservation, every provider edit would wipe the
    // user's aliases. This pins the regression so a future refactor
    // that touches writeStored has to keep both fields.
    await writeStoredMediaConfig({
      providers: {},
      aliases: { 'doubao-seedream-3-0-t2i-250415': 'doubao-seedream-5-0' },
    });
    await writeConfig(projectRoot, {
      providers: {
        openai: { apiKey: 'sk-key', baseUrl: '' },
      },
    });
    const onDisk = JSON.parse(
      await readFile(
        path.join(projectRoot, '.od', 'media-config.json'),
        'utf8',
      ),
    );
    expect(onDisk.providers.openai).toMatchObject({ apiKey: 'sk-key' });
    expect(onDisk.aliases).toEqual({
      'doubao-seedream-3-0-t2i-250415': 'doubao-seedream-5-0',
    });
    expect(
      await resolveModelAlias(projectRoot, 'doubao-seedream-3-0-t2i-250415'),
    ).toBe('doubao-seedream-5-0');
  });
});

describe('seedProviderIfMissing', () => {
  let projectRoot: string;
  const SENSEAUDIO_ENV_KEYS = ['OD_SENSEAUDIO_API_KEY', 'SENSEAUDIO_API_KEY'];
  const originalEnv = Object.fromEntries(
    SENSEAUDIO_ENV_KEYS.map((key) => [key, process.env[key]]),
  );
  const originalMediaConfigDir = process.env.OD_MEDIA_CONFIG_DIR;
  const originalDataDir = process.env.OD_DATA_DIR;

  beforeEach(async () => {
    projectRoot = await mkdtemp(path.join(tmpdir(), 'od-media-seed-'));
    for (const key of SENSEAUDIO_ENV_KEYS) {
      delete process.env[key];
    }
    delete process.env.OD_MEDIA_CONFIG_DIR;
    delete process.env.OD_DATA_DIR;
  });

  afterEach(async () => {
    for (const key of SENSEAUDIO_ENV_KEYS) {
      if (originalEnv[key] == null) {
        delete process.env[key];
      } else {
        process.env[key] = originalEnv[key];
      }
    }
    if (originalMediaConfigDir == null) {
      delete process.env.OD_MEDIA_CONFIG_DIR;
    } else {
      process.env.OD_MEDIA_CONFIG_DIR = originalMediaConfigDir;
    }
    if (originalDataDir == null) {
      delete process.env.OD_DATA_DIR;
    } else {
      process.env.OD_DATA_DIR = originalDataDir;
    }
    await rm(projectRoot, { recursive: true, force: true });
  });

  async function writeStored(data: unknown) {
    const file = path.join(projectRoot, '.od', 'media-config.json');
    await mkdir(path.dirname(file), { recursive: true });
    await writeFile(file, JSON.stringify(data), 'utf8');
  }

  async function readStoredJson(): Promise<unknown> {
    const file = path.join(projectRoot, '.od', 'media-config.json');
    const raw = await readFile(file, 'utf8');
    return JSON.parse(raw);
  }

  it('writes a fresh entry when the slot is empty', async () => {
    const wrote = await seedProviderIfMissing(projectRoot, 'senseaudio', {
      apiKey: 'sa-test-key',
      baseUrl: 'https://api.senseaudio.cn',
    });
    expect(wrote).toBe(true);
    const stored = await readStoredJson();
    expect(stored).toEqual({
      providers: {
        senseaudio: {
          apiKey: 'sa-test-key',
          baseUrl: 'https://api.senseaudio.cn',
        },
      },
    });
  });

  it('no-ops and preserves the stored key when one is already configured', async () => {
    await writeStored({
      providers: {
        senseaudio: { apiKey: 'pre-existing-key', baseUrl: 'https://existing.example' },
      },
    });
    const wrote = await seedProviderIfMissing(projectRoot, 'senseaudio', {
      apiKey: 'newer-byok-key',
      baseUrl: 'https://api.senseaudio.cn',
    });
    expect(wrote).toBe(false);
    const stored = (await readStoredJson()) as { providers: Record<string, unknown> };
    expect(stored.providers.senseaudio).toEqual({
      apiKey: 'pre-existing-key',
      baseUrl: 'https://existing.example',
    });
  });

  it('preserves every other provider and aliases when seeding', async () => {
    await writeStored({
      providers: {
        openai: { apiKey: 'sk-openai', baseUrl: 'https://api.openai.com/v1' },
        volcengine: { apiKey: 'ark-key', baseUrl: 'https://ark.cn-beijing.volces.com/api/v3' },
      },
      aliases: { 'doubao-seedream-3-0-t2i-250415': 'doubao-seedream-5-0' },
    });
    const wrote = await seedProviderIfMissing(projectRoot, 'senseaudio', {
      apiKey: 'sa-new',
    });
    expect(wrote).toBe(true);
    const stored = (await readStoredJson()) as {
      providers: Record<string, unknown>;
      aliases: Record<string, string>;
    };
    expect(stored.providers.openai).toEqual({
      apiKey: 'sk-openai',
      baseUrl: 'https://api.openai.com/v1',
    });
    expect(stored.providers.volcengine).toEqual({
      apiKey: 'ark-key',
      baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
    });
    expect(stored.providers.senseaudio).toEqual({ apiKey: 'sa-new' });
    expect(stored.aliases).toEqual({
      'doubao-seedream-3-0-t2i-250415': 'doubao-seedream-5-0',
    });
  });

  it('no-ops when an env var resolves a key for the provider', async () => {
    process.env.OD_SENSEAUDIO_API_KEY = 'env-key';
    const wrote = await seedProviderIfMissing(projectRoot, 'senseaudio', {
      apiKey: 'sa-byok-key',
      baseUrl: 'https://api.senseaudio.cn',
    });
    expect(wrote).toBe(false);
    await expect(readStoredJson()).rejects.toThrow();
  });

  it('no-ops on empty apiKey', async () => {
    const wrote = await seedProviderIfMissing(projectRoot, 'senseaudio', {
      apiKey: '',
      baseUrl: 'https://api.senseaudio.cn',
    });
    expect(wrote).toBe(false);
    await expect(readStoredJson()).rejects.toThrow();
  });

  it('no-ops for unknown provider ids', async () => {
    const wrote = await seedProviderIfMissing(projectRoot, 'not-a-provider', {
      apiKey: 'whatever',
    });
    expect(wrote).toBe(false);
    await expect(readStoredJson()).rejects.toThrow();
  });

  it('resolves the seeded key through resolveProviderConfig', async () => {
    await seedProviderIfMissing(projectRoot, 'senseaudio', {
      apiKey: 'sa-final',
      baseUrl: 'https://api.senseaudio.cn',
    });
    const resolved = await resolveProviderConfig(projectRoot, 'senseaudio');
    expect(resolved).toEqual({
      apiKey: 'sa-final',
      baseUrl: 'https://api.senseaudio.cn',
    });
  });
});
