// @ts-nocheck
// Tests for `apps/daemon/src/finalize-design.ts` — fills in across phases
// D-I. Phase D adds the truncation helper tests; phases E-I extend.
//
// Per memory `project_open_design_493_merged.md`: this file uses
// `import fs from 'node:fs'` (default import) so `vi.spyOn(fs, '<fn>')`
// can redefine properties on the underlying CJS exports object. ESM
// namespace import (`import * as fs from 'node:fs'`) gives a frozen
// Module Namespace Object that `vi.spyOn` cannot mutate.

import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import type http from 'node:http';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
  closeDatabase,
  insertConversation,
  insertProject,
  openDatabase,
  upsertMessage,
} from '../src/db.js';
import { isSafeId, writeProjectFile } from '../src/projects.js';
import {
  appendVersionedApiPath,
  buildSynthesisPrompt,
  callAnthropicWithRetry,
  extractDesignMd,
  finalizeDesignPackage,
  FinalizePackageLockedError,
  FinalizeUpstreamError,
  resolveCurrentArtifact,
  truncateTranscriptForPrompt,
} from '../src/finalize-design.js';

void appendVersionedApiPath;

// Touch the imports so the unused-import linter stays quiet on the scaffold.
void finalizeDesignPackage;
void FinalizePackageLockedError;
void FinalizeUpstreamError;

const PROJECT_ID = 'project-1';
let tempDir: string | null = null;
let projectsRoot: string | null = null;

afterEach(() => {
  closeDatabase();
  vi.restoreAllMocks();
  if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
  tempDir = null;
  projectsRoot = null;
});

function setupResolverFixture(): { db: any; projectsRoot: string } {
  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-finalize-'));
  const db = openDatabase(tempDir);
  insertProject(db, {
    id: PROJECT_ID,
    name: 'Project',
    createdAt: 1,
    updatedAt: 1,
  });
  projectsRoot = path.join(tempDir, 'projects');
  fs.mkdirSync(path.join(projectsRoot, PROJECT_ID), { recursive: true });
  return { db, projectsRoot };
}

function setActiveTab(db: any, name: string) {
  db.prepare(
    `INSERT INTO tabs (project_id, name, position, is_active) VALUES (?, ?, ?, 1)`,
  ).run(PROJECT_ID, name, 0);
}

const HEADER = JSON.stringify({
  kind: 'header',
  schemaVersion: 2,
  projectId: 'proj-1',
  exportedAt: '2026-05-07T14:00:00.000Z',
  conversationCount: 1,
  messageCount: 100,
});

function buildSyntheticJsonl(messageCount: number, perMessageBytes: number): string {
  // Each message line is roughly `perMessageBytes` long after stringify.
  const lines = [HEADER, JSON.stringify({ kind: 'conversation', id: 'c1', title: 't', createdAt: 1, updatedAt: 1 })];
  const padBytes = Math.max(0, perMessageBytes - 80);
  const filler = 'x'.repeat(padBytes);
  for (let i = 0; i < messageCount; i += 1) {
    lines.push(JSON.stringify({
      kind: 'message',
      id: `m${i}`,
      role: 'user',
      position: i,
      blocks: [{ type: 'text', text: `msg-${i}-${filler}` }],
    }));
  }
  return lines.join('\n') + '\n';
}

describe('truncateTranscriptForPrompt', () => {
  it('returns the input verbatim when the JSONL fits under the 384 KiB cap', () => {
    // 50 messages at ~100 bytes each = ~5 KB total; well under the cap.
    const jsonl = buildSyntheticJsonl(50, 100);
    expect(Buffer.byteLength(jsonl, 'utf8')).toBeLessThan(384 * 1024);

    const out = truncateTranscriptForPrompt(jsonl);

    expect(out).toBe(jsonl);
    expect(out).not.toContain('"kind":"truncated"');
    // Every message line round-trips.
    for (let i = 0; i < 50; i += 1) {
      expect(out).toContain(`"id":"m${i}"`);
    }
  });

  it('head+tail truncates with a single marker line when the JSONL exceeds the 384 KiB cap', () => {
    // 800 messages at ~1 KB each = ~800 KB total; comfortably above the cap.
    const jsonl = buildSyntheticJsonl(800, 1024);
    expect(Buffer.byteLength(jsonl, 'utf8')).toBeGreaterThan(384 * 1024);

    const out = truncateTranscriptForPrompt(jsonl);

    // Output is bounded by the cap (allow a small tolerance for the
    // marker + reservation slack).
    expect(Buffer.byteLength(out, 'utf8')).toBeLessThanOrEqual(384 * 1024);

    // Header line survives.
    expect(out.split('\n')[0]).toBe(HEADER);

    // Exactly one truncation marker present, with a non-zero omittedBytes.
    const markerMatches = out.match(/\{"kind":"truncated","reason":"size","omittedBytes":\d+\}/g);
    expect(markerMatches).not.toBeNull();
    expect(markerMatches).toHaveLength(1);
    const omittedBytes = Number(markerMatches![0].match(/"omittedBytes":(\d+)/)![1]);
    expect(omittedBytes).toBeGreaterThan(0);

    // Both ends preserved: first message after header survives; last
    // message before the trailing newline survives.
    expect(out).toContain('"id":"m0"');
    expect(out).toContain('"id":"m799"');

    // Middle messages (e.g. m400) should NOT all survive — at least one
    // must be omitted; otherwise we wouldn't have needed the marker.
    const surviving = (out.match(/"id":"m\d+"/g) || []).map((s) => Number(s.match(/m(\d+)/)![1]));
    expect(surviving.length).toBeLessThan(800);
    expect(surviving).toContain(0);
    expect(surviving).toContain(799);
  });
});

describe('resolveCurrentArtifact', () => {
  it('returns the active-tab artifact when its sidecar is present, even if a newer artifact exists elsewhere', async () => {
    const { db, projectsRoot } = setupResolverFixture();

    // Older artifact - active tab points here.
    await writeProjectFile(projectsRoot, PROJECT_ID, 'pinned.html', '<p>pinned body</p>', {
      artifactManifest: {
        version: 1,
        kind: 'html',
        title: 'Pinned',
        entry: 'pinned.html',
        renderer: 'html',
        exports: ['html'],
        updatedAt: '2026-05-01T00:00:00.000Z',
      },
    });
    // Newer artifact - NOT in the active tab.
    await writeProjectFile(projectsRoot, PROJECT_ID, 'newer.html', '<p>newer body</p>', {
      artifactManifest: {
        version: 1,
        kind: 'html',
        title: 'Newer',
        entry: 'newer.html',
        renderer: 'html',
        exports: ['html'],
        updatedAt: '2026-05-07T00:00:00.000Z',
      },
    });
    setActiveTab(db, 'pinned.html');

    const out = await resolveCurrentArtifact(db, projectsRoot, PROJECT_ID);
    expect(out).not.toBeNull();
    expect(out!.name).toBe('pinned.html');
    expect(out!.body).toBe('<p>pinned body</p>');
  });

  it('falls through to newest .artifact.json when active tab points at a non-artifact file', async () => {
    const { db, projectsRoot } = setupResolverFixture();

    // README.md - no artifact sidecar - active tab points here.
    await writeProjectFile(projectsRoot, PROJECT_ID, 'README.md', '# notes\n');
    // The actual artifact - NOT in active tab.
    await writeProjectFile(projectsRoot, PROJECT_ID, 'design.html', '<p>design</p>', {
      artifactManifest: {
        version: 1,
        kind: 'html',
        title: 'Design',
        entry: 'design.html',
        renderer: 'html',
        exports: ['html'],
        updatedAt: '2026-05-07T00:00:00.000Z',
      },
    });
    setActiveTab(db, 'README.md');

    const out = await resolveCurrentArtifact(db, projectsRoot, PROJECT_ID);
    expect(out).not.toBeNull();
    expect(out!.name).toBe('design.html');
    expect(out!.body).toBe('<p>design</p>');
  });

  it('returns null when no active tab and no .artifact.json sidecars exist', async () => {
    const { db, projectsRoot } = setupResolverFixture();

    // README.md only - no artifact sidecars anywhere.
    await writeProjectFile(projectsRoot, PROJECT_ID, 'README.md', '# notes\n');

    const out = await resolveCurrentArtifact(db, projectsRoot, PROJECT_ID);
    expect(out).toBeNull();
  });

  it('reconciles an active HTML artifact that is missing its sidecar', async () => {
    const { db, projectsRoot } = setupResolverFixture();

    // Mirrors a resumed/Continue run that wrote the HTML deliverable but
    // terminated before durable artifact sidecar registration completed.
    await writeProjectFile(projectsRoot, PROJECT_ID, 'resumed.html', '<p>resumed</p>');
    setActiveTab(db, 'resumed.html');

    const out = await resolveCurrentArtifact(db, projectsRoot, PROJECT_ID);

    expect(out).not.toBeNull();
    expect(out!.name).toBe('resumed.html');
    expect(out!.body).toBe('<p>resumed</p>');
    expect(out!.manifest?.entry).toBe('resumed.html');
    expect(out!.manifest?.kind).toBe('html');
    const sidecarPath = path.join(projectsRoot, PROJECT_ID, 'resumed.html.artifact.json');
    expect(fs.existsSync(sidecarPath)).toBe(true);
    const sidecar = JSON.parse(fs.readFileSync(sidecarPath, 'utf8')) as {
      entry?: string;
      kind?: string;
      metadata?: { inferred?: boolean; reconciled?: boolean };
    };
    expect(sidecar.entry).toBe('resumed.html');
    expect(sidecar.kind).toBe('html');
    expect(sidecar.metadata?.inferred).toBe(true);
    expect(sidecar.metadata?.reconciled).toBe(true);
  });

  it('keeps a newer persisted artifact ahead of an older reconciled HTML file when no tab is active', async () => {
    const { db, projectsRoot } = setupResolverFixture();
    const projectDir = path.join(projectsRoot, PROJECT_ID);
    const staleHtmlMtime = new Date('2026-05-01T00:00:00.000Z');

    await writeProjectFile(projectsRoot, PROJECT_ID, 'newer.html', '<p>newer</p>', {
      artifactManifest: {
        version: 1,
        kind: 'html',
        title: 'Newer',
        entry: 'newer.html',
        renderer: 'html',
        exports: ['html'],
        updatedAt: '2026-05-07T00:00:00.000Z',
      },
    });
    await writeProjectFile(projectsRoot, PROJECT_ID, 'stale.html', '<p>stale</p>');
    fs.utimesSync(path.join(projectDir, 'stale.html'), staleHtmlMtime, staleHtmlMtime);

    const out = await resolveCurrentArtifact(db, projectsRoot, PROJECT_ID);

    expect(out).not.toBeNull();
    expect(out!.name).toBe('newer.html');
    expect(out!.body).toBe('<p>newer</p>');
    const sidecarPath = path.join(projectDir, 'stale.html.artifact.json');
    expect(fs.existsSync(sidecarPath)).toBe(true);
    const sidecar = JSON.parse(fs.readFileSync(sidecarPath, 'utf8')) as {
      updatedAt?: string;
      metadata?: { reconciled?: boolean };
    };
    expect(sidecar.metadata?.reconciled).toBe(true);
    expect(sidecar.updatedAt).toBe(staleHtmlMtime.toISOString());
  });

  // PR #832 P3 fix from @lefarcen: a malformed tabs row (e.g. an
  // attacker with DB write access setting tabs.name = `../../../etc/passwd`)
  // would otherwise cause path.join to compose a probe URL outside the
  // project dir before readProjectFile's path-safety check kicked in.
  // Post-fix: the resolver runs the tab name through validateProjectPath
  // first; an invalid name falls through to the newest-artifact branch.
  it('falls through (does not throw) when active tab name contains traversal segments', async () => {
    const { db, projectsRoot } = setupResolverFixture();

    // A real artifact exists.
    await writeProjectFile(projectsRoot, PROJECT_ID, 'design.html', '<p>real</p>', {
      artifactManifest: {
        version: 1,
        kind: 'html',
        title: 'Design',
        entry: 'design.html',
        renderer: 'html',
        exports: ['html'],
        updatedAt: '2026-05-07T00:00:00.000Z',
      },
    });
    // Inject a malformed tabs row directly via SQL — bypasses the
    // production tab-creation code path which would normally validate.
    db.prepare(
      `INSERT INTO tabs (project_id, name, position, is_active) VALUES (?, ?, 0, 1)`,
    ).run(PROJECT_ID, '../../../etc/passwd');

    const out = await resolveCurrentArtifact(db, projectsRoot, PROJECT_ID);
    // The resolver must NOT throw and must NOT return the malformed name.
    // It falls through to the newest-artifact branch and returns the
    // real artifact instead.
    expect(out).not.toBeNull();
    expect(out!.name).toBe('design.html');
  });
});

describe('buildSynthesisPrompt', () => {
  const FIXED_NOW = new Date('2026-05-07T14:00:00.000Z');
  const TRANSCRIPT_FIXTURE =
    JSON.stringify({ kind: 'header', schemaVersion: 2, projectId: 'p1', messageCount: 2 }) +
    '\n' +
    JSON.stringify({ kind: 'message', id: 'm1', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }) +
    '\n' +
    JSON.stringify({ kind: 'message', id: 'm2', role: 'assistant', blocks: [{ type: 'text', text: 'hello' }] }) +
    '\n';

  it('includes the transcript JSONL verbatim and the generation context', () => {
    const out = buildSynthesisPrompt({
      projectId: 'p1',
      transcriptJsonl: TRANSCRIPT_FIXTURE,
      transcriptMessageCount: 2,
      designSystemId: 'shadcn',
      designSystemBody: '# shadcn\nminimal\n',
      artifact: { name: 'design.html', body: '<p>artifact</p>', manifest: null },
      now: FIXED_NOW,
    });

    expect(out.systemPrompt).toContain('# DESIGN.md');
    expect(out.systemPrompt).toContain('## Provenance');

    expect(out.userPrompt).toContain('## Transcript (JSONL)');
    expect(out.userPrompt).toContain(TRANSCRIPT_FIXTURE);
    expect(out.userPrompt).toContain('## Active design system: shadcn');
    expect(out.userPrompt).toContain('# shadcn\nminimal\n');
    expect(out.userPrompt).toContain('## Current artifact: design.html');
    expect(out.userPrompt).toContain('<p>artifact</p>');
    expect(out.userPrompt).toContain('Generated at: 2026-05-07T14:00:00.000Z');
    expect(out.userPrompt).toContain('Project ID: p1');
    expect(out.userPrompt).toContain('Transcript message count: 2');
    expect(out.userPrompt).toContain('Synthesize DESIGN.md per the system instructions.');
  });

  it('falls back to "none" + parenthetical when no design system is selected', () => {
    const out = buildSynthesisPrompt({
      projectId: 'p1',
      transcriptJsonl: TRANSCRIPT_FIXTURE,
      transcriptMessageCount: 2,
      designSystemId: null,
      designSystemBody: null,
      artifact: { name: 'design.html', body: '<p>artifact</p>', manifest: null },
      now: FIXED_NOW,
    });

    expect(out.userPrompt).toContain('## Active design system: none');
    expect(out.userPrompt).toContain('(no design system selected for this project)');
  });

  it('falls back to "none" + parenthetical when no artifact is in scope', () => {
    const out = buildSynthesisPrompt({
      projectId: 'p1',
      transcriptJsonl: TRANSCRIPT_FIXTURE,
      transcriptMessageCount: 2,
      designSystemId: 'shadcn',
      designSystemBody: '# shadcn\n',
      artifact: null,
      now: FIXED_NOW,
    });

    expect(out.userPrompt).toContain('## Current artifact: none');
    expect(out.userPrompt).toContain('(no artifact in scope for this finalize)');
  });
});

describe('callAnthropicWithRetry', () => {
  const baseParams = {
    apiKey: 'sk-test-key',
    baseUrl: 'https://api.anthropic.com',
    model: 'claude-opus-4-7',
    maxTokens: 16000,
    systemPrompt: 'sys',
    userPrompt: 'usr',
    _sleepMs: async () => {},
  };

  function jsonResponse(status: number, body: any): Response {
    return new Response(JSON.stringify(body), {
      status,
      headers: { 'content-type': 'application/json' },
    });
  }
  function textResponse(status: number, body: string): Response {
    return new Response(body, { status });
  }

  it('throws FinalizeUpstreamError(401) on auth failure (no retry on 4xx non-429)', async () => {
    const fetchImpl = vi.fn(async () => textResponse(401, '{"error":{"type":"authentication_error","message":"invalid x-api-key"}}'));
    await expect(callAnthropicWithRetry({ ...baseParams, fetchImpl })).rejects.toMatchObject({
      name: 'FinalizeUpstreamError',
      status: 401,
    });
    expect(fetchImpl).toHaveBeenCalledTimes(1);
  });

  it('retries once on 429 and resolves when the second response succeeds', async () => {
    const ok = jsonResponse(200, { content: [{ type: 'text', text: 'DESIGN.md body' }], usage: { input_tokens: 1, output_tokens: 1 } });
    const fetchImpl = vi
      .fn<any, any>()
      .mockResolvedValueOnce(textResponse(429, '{"error":"rate limited"}'))
      .mockResolvedValueOnce(ok);

    const response = await callAnthropicWithRetry({ ...baseParams, fetchImpl });
    expect(response.status).toBe(200);
    expect(fetchImpl).toHaveBeenCalledTimes(2);
  });

  it('throws FinalizeUpstreamError(503) when both attempts return 5xx', async () => {
    const fetchImpl = vi
      .fn<any, any>()
      .mockResolvedValueOnce(textResponse(503, 'service unavailable'))
      .mockResolvedValueOnce(textResponse(503, 'service unavailable'));

    await expect(callAnthropicWithRetry({ ...baseParams, fetchImpl })).rejects.toMatchObject({
      name: 'FinalizeUpstreamError',
      status: 503,
    });
    expect(fetchImpl).toHaveBeenCalledTimes(2);
  });

  it('propagates AbortError from fetch when the signal is aborted', async () => {
    const controller = new AbortController();
    const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => {
      // Mirror native fetch's behavior: throw AbortError when init.signal is aborted.
      if (init?.signal?.aborted) {
        const err = new Error('aborted');
        err.name = 'AbortError';
        throw err;
      }
      throw new Error('fetch should never be called with non-aborted signal in this test');
    });
    controller.abort();

    await expect(
      callAnthropicWithRetry({ ...baseParams, fetchImpl, signal: controller.signal }),
    ).rejects.toMatchObject({ name: 'AbortError' });
  });
});

describe('extractDesignMd', () => {
  it('concatenates text blocks in order', () => {
    const payload = {
      content: [
        { type: 'text', text: '# DESIGN.md\n## Summary\n' },
        { type: 'text', text: 'body continues here.\n' },
      ],
      usage: { input_tokens: 1, output_tokens: 1 },
    };
    expect(extractDesignMd(payload)).toBe('# DESIGN.md\n## Summary\nbody continues here.\n');
  });

  it('throws FinalizeUpstreamError(502) when the response shape has no content array', () => {
    expect(() => extractDesignMd({ unexpected: true })).toThrow(FinalizeUpstreamError);
    try {
      extractDesignMd({ unexpected: true });
    } catch (err: any) {
      expect(err.status).toBe(502);
    }
  });

  it('throws FinalizeUpstreamError(502) when content array has zero text blocks', () => {
    expect(() => extractDesignMd({ content: [{ type: 'tool_use', id: 'x', name: 'y', input: {} }] })).toThrow(FinalizeUpstreamError);
  });
});

describe('finalizeDesignPackage (pipeline integration)', () => {
  function setupPipeline(
    opts: { designSystemId?: string | null; designSystemBody?: string | null } = {},
  ): { db: any; projectsRoot: string; designSystemsRoot: string } {
    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-finalize-pipe-'));
    const designSystemsRoot = path.join(tempDir, 'design-systems');
    fs.mkdirSync(designSystemsRoot, { recursive: true });
    if (opts.designSystemId && opts.designSystemBody !== null) {
      fs.mkdirSync(path.join(designSystemsRoot, opts.designSystemId), { recursive: true });
      fs.writeFileSync(
        path.join(designSystemsRoot, opts.designSystemId, 'DESIGN.md'),
        opts.designSystemBody ?? '# default DESIGN.md\n',
      );
    }

    const db = openDatabase(tempDir);
    insertProject(db, {
      id: PROJECT_ID,
      name: 'Project',
      designSystemId: opts.designSystemId === undefined ? 'shadcn' : opts.designSystemId,
      createdAt: 1,
      updatedAt: 1,
    });
    projectsRoot = path.join(tempDir, 'projects');
    fs.mkdirSync(path.join(projectsRoot, PROJECT_ID), { recursive: true });

    insertConversation(db, {
      id: 'c1',
      projectId: PROJECT_ID,
      title: 'Greeting',
      createdAt: 100,
      updatedAt: 100,
    });
    upsertMessage(db, 'c1', {
      id: 'm1',
      role: 'user',
      content: '',
      events: [{ kind: 'text', text: 'design me a landing page' }],
    });
    upsertMessage(db, 'c1', {
      id: 'm2',
      role: 'assistant',
      content: '',
      events: [{ kind: 'text', text: 'here is the html' }],
    });

    return { db, projectsRoot, designSystemsRoot };
  }

  function happyFetch(designMd: string): typeof globalThis.fetch {
    return vi.fn(async () =>
      new Response(
        JSON.stringify({
          content: [{ type: 'text', text: designMd }],
          usage: { input_tokens: 1234, output_tokens: 567 },
        }),
        { status: 200, headers: { 'content-type': 'application/json' } },
      ),
    ) as any;
  }

  it('writes DESIGN.md atomically on the happy path', async () => {
    const { db, projectsRoot, designSystemsRoot } = setupPipeline({
      designSystemId: 'shadcn',
      designSystemBody: '# shadcn\n## tone\nminimal, opinionated.\n',
    });

    const fetchImpl = happyFetch('# DESIGN.md\n## Summary\nA landing page.\n');
    const result = await finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
      apiKey: 'sk-test',
      baseUrl: 'https://api.anthropic.com',
      model: 'claude-opus-4-7',
      fetchImpl,
    } as any);

    expect(result.designMdPath).toBe(path.join(projectsRoot, PROJECT_ID, 'DESIGN.md'));
    expect(fs.existsSync(result.designMdPath)).toBe(true);
    expect(fs.readFileSync(result.designMdPath, 'utf8')).toBe(
      '# DESIGN.md\n## Summary\nA landing page.\n',
    );
    // No leftover .tmp files.
    const dirEntries = fs.readdirSync(path.join(projectsRoot, PROJECT_ID));
    expect(dirEntries.filter((n) => n.startsWith('DESIGN.md.tmp.'))).toEqual([]);
    // Lock is released.
    expect(dirEntries).not.toContain('.finalize.lock');
  });

  it('uses Google Gemini generateContent when finalize protocol is google', async () => {
    const { db, projectsRoot, designSystemsRoot } = setupPipeline({
      designSystemId: 'shadcn',
      designSystemBody: '# shadcn\n',
    });
    const fetchImpl = vi.fn(async (_url: string, _init: RequestInit) =>
      new Response(
        JSON.stringify({
          candidates: [
            {
              content: {
                parts: [
                  { text: '# DESIGN.md\n' },
                  { text: '## Summary\nGemini synthesis.\n' },
                ],
              },
            },
          ],
          usageMetadata: {
            promptTokenCount: 4321,
            candidatesTokenCount: 876,
          },
        }),
        { status: 200, headers: { 'content-type': 'application/json' } },
      ),
    );

    const result = await finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
      protocol: 'google',
      apiKey: 'AIza-test-key',
      baseUrl: 'https://generativelanguage.googleapis.com',
      model: 'gemini-2.0-flash',
      fetchImpl: fetchImpl as any,
    } as any);

    expect(result.model).toBe('gemini-2.0-flash');
    expect(result.inputTokens).toBe(4321);
    expect(result.outputTokens).toBe(876);
    expect(fs.readFileSync(result.designMdPath, 'utf8')).toBe(
      '# DESIGN.md\n## Summary\nGemini synthesis.\n',
    );

    expect(fetchImpl).toHaveBeenCalledTimes(1);
    const [url, init] = fetchImpl.mock.calls[0]!;
    expect(url).toBe(
      'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent',
    );
    expect((init as RequestInit).headers).toMatchObject({
      'content-type': 'application/json',
      'x-goog-api-key': 'AIza-test-key',
    });
    const body = JSON.parse((init as RequestInit).body as string);
    expect(body.systemInstruction.parts[0].text).toContain('# DESIGN.md');
    expect(body.contents).toHaveLength(1);
    expect(body.contents[0].role).toBe('user');
    expect(body.contents[0].parts[0].text).toContain('Synthesize DESIGN.md');
    expect(body.generationConfig.maxOutputTokens).toBe(16000);
  });

  it('response carries every documented field with correct types', async () => {
    const { db, projectsRoot, designSystemsRoot } = setupPipeline({
      designSystemId: 'shadcn',
      designSystemBody: '# shadcn\n',
    });
    const fetchImpl = happyFetch('# DESIGN.md\nbody\n');
    const result = await finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
      apiKey: 'sk-test',
      baseUrl: 'https://api.anthropic.com',
      model: 'claude-opus-4-7',
      fetchImpl,
    } as any);

    expect(typeof result.designMdPath).toBe('string');
    expect(typeof result.bytesWritten).toBe('number');
    expect(result.bytesWritten).toBeGreaterThan(0);
    expect(result.model).toBe('claude-opus-4-7');
    expect(result.inputTokens).toBe(1234);
    expect(result.outputTokens).toBe(567);
    expect(result.artifact).toBeNull(); // no artifact seeded
    expect(result.transcriptMessageCount).toBe(2);
    expect(result.designSystemId).toBe('shadcn');
  });

  it('emits design system "none" in the prompt when no design_system_id is set', async () => {
    const { db, projectsRoot, designSystemsRoot } = setupPipeline({
      designSystemId: null,
    });
    const fetchImpl = vi.fn(async (_url: string, init: RequestInit) => {
      // Capture the body for assertion.
      const body = JSON.parse(init.body as string);
      expect(body.system).toContain('# DESIGN.md');
      expect(body.messages[0].content).toContain('## Active design system: none');
      expect(body.messages[0].content).toContain(
        '(no design system selected for this project)',
      );
      return new Response(
        JSON.stringify({
          content: [{ type: 'text', text: '# DESIGN.md\nbody\n' }],
          usage: { input_tokens: 1, output_tokens: 1 },
        }),
        { status: 200, headers: { 'content-type': 'application/json' } },
      );
    });

    const result = await finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
      apiKey: 'sk-test',
      baseUrl: 'https://api.anthropic.com',
      model: 'claude-opus-4-7',
      fetchImpl: fetchImpl as any,
    } as any);
    expect(result.designSystemId).toBeNull();
    expect(fetchImpl).toHaveBeenCalledTimes(1);
  });

  it('throws FinalizePackageLockedError when .finalize.lock is already held', async () => {
    const { db, projectsRoot, designSystemsRoot } = setupPipeline();
    const lockPath = path.join(projectsRoot, PROJECT_ID, '.finalize.lock');
    fs.writeFileSync(lockPath, '');
    const fetchImpl = happyFetch('# DESIGN.md\nbody\n');

    await expect(
      finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
        apiKey: 'sk-test',
        baseUrl: 'https://api.anthropic.com',
        model: 'claude-opus-4-7',
        fetchImpl,
      } as any),
    ).rejects.toBeInstanceOf(FinalizePackageLockedError);

    // DESIGN.md must NOT have been written; the pre-existing lock prevented it.
    expect(fs.existsSync(path.join(projectsRoot, PROJECT_ID, 'DESIGN.md'))).toBe(false);

    // The pre-existing lock must remain — we did not own it, so we must not unlink.
    expect(fs.existsSync(lockPath)).toBe(true);
  });

  it('replaces an existing DESIGN.md atomically on a second finalize', async () => {
    const { db, projectsRoot, designSystemsRoot } = setupPipeline();
    const finalPath = path.join(projectsRoot, PROJECT_ID, 'DESIGN.md');

    await finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
      apiKey: 'sk-test',
      baseUrl: 'https://api.anthropic.com',
      model: 'claude-opus-4-7',
      fetchImpl: happyFetch('# DESIGN.md\nfirst run\n'),
    } as any);
    expect(fs.readFileSync(finalPath, 'utf8')).toBe('# DESIGN.md\nfirst run\n');

    // Inject a sentinel between finalize calls.
    fs.writeFileSync(finalPath, 'sentinel\n');
    expect(fs.readFileSync(finalPath, 'utf8')).toBe('sentinel\n');

    await finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
      apiKey: 'sk-test',
      baseUrl: 'https://api.anthropic.com',
      model: 'claude-opus-4-7',
      fetchImpl: happyFetch('# DESIGN.md\nsecond run\n'),
    } as any);
    expect(fs.readFileSync(finalPath, 'utf8')).toBe('# DESIGN.md\nsecond run\n');
  });

  it('cleans up tmp file AND lock file on every error path', async () => {
    const { db, projectsRoot, designSystemsRoot } = setupPipeline();

    // Force a crash mid-write: spy on writeFileSync and throw on the
    // DESIGN.md.tmp.* path. Other writeFileSync usages (e.g. transcript-
    // export within this same call) must continue to work.
    const realWrite = fs.writeFileSync;
    vi.spyOn(fs, 'writeFileSync').mockImplementation((p: any, ...rest: any[]) => {
      if (typeof p === 'string' && p.includes('DESIGN.md.tmp.')) {
        throw new Error('disk full');
      }
      return (realWrite as any)(p, ...rest);
    });

    await expect(
      finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
        apiKey: 'sk-test',
        baseUrl: 'https://api.anthropic.com',
        model: 'claude-opus-4-7',
        fetchImpl: happyFetch('# DESIGN.md\nbody\n'),
      } as any),
    ).rejects.toThrow(/disk full/);

    const dirEntries = fs.readdirSync(path.join(projectsRoot, PROJECT_ID));
    expect(dirEntries.filter((n) => n.startsWith('DESIGN.md.tmp.'))).toEqual([]);
    expect(dirEntries).not.toContain('DESIGN.md');
    expect(dirEntries).not.toContain('.finalize.lock');
  });

  it('uses the default https://api.anthropic.com baseUrl when baseUrl is omitted', async () => {
    const { db, projectsRoot, designSystemsRoot } = setupPipeline();
    const fetchImpl = vi.fn(async (url: string) => {
      expect(url.startsWith('https://api.anthropic.com/v1/messages')).toBe(true);
      return new Response(
        JSON.stringify({
          content: [{ type: 'text', text: '# DESIGN.md\n' }],
          usage: { input_tokens: 1, output_tokens: 1 },
        }),
        { status: 200, headers: { 'content-type': 'application/json' } },
      );
    });

    await finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
      apiKey: 'sk-test',
      // baseUrl deliberately omitted
      model: 'claude-opus-4-7',
      fetchImpl: fetchImpl as any,
    } as any);
    expect(fetchImpl).toHaveBeenCalledTimes(1);
  });

  // PR #832 P1 fix from @lefarcen: imported-folder projects (created via
  // /api/import/folder) carry metadata.baseDir which redirects file IO to
  // the user's actual folder. Pre-fix, the pipeline called projectDir()
  // unconditionally so DESIGN.md landed in the hidden .od/projects/<id>
  // dir instead of metadata.baseDir; the resolver also missed the user's
  // real artifacts. Post-fix, both call sites use resolveProjectDir.
  it('writes DESIGN.md under metadata.baseDir for imported-folder projects', async () => {
    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'od-finalize-imported-'));
    const designSystemsRoot = path.join(tempDir, 'design-systems');
    fs.mkdirSync(designSystemsRoot, { recursive: true });
    fs.mkdirSync(path.join(designSystemsRoot, 'shadcn'), { recursive: true });
    fs.writeFileSync(path.join(designSystemsRoot, 'shadcn', 'DESIGN.md'), '# shadcn\n');

    // The user's actual folder lives outside .od/projects/.
    const userFolder = path.join(tempDir, 'user-imported-folder');
    fs.mkdirSync(userFolder, { recursive: true });

    const db = openDatabase(tempDir);
    insertProject(db, {
      id: PROJECT_ID,
      name: 'Imported',
      designSystemId: 'shadcn',
      metadata: { baseDir: userFolder },
      createdAt: 1,
      updatedAt: 1,
    });
    projectsRoot = path.join(tempDir, 'projects');
    fs.mkdirSync(path.join(projectsRoot, PROJECT_ID), { recursive: true });

    insertConversation(db, {
      id: 'c1',
      projectId: PROJECT_ID,
      title: 't',
      createdAt: 100,
      updatedAt: 100,
    });
    upsertMessage(db, 'c1', {
      id: 'm1',
      role: 'user',
      content: '',
      events: [{ kind: 'text', text: 'hi' }],
    });

    const result = await finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
      apiKey: 'sk-test',
      baseUrl: 'https://api.anthropic.com',
      model: 'claude-opus-4-7',
      fetchImpl: happyFetch('# DESIGN.md\nimported folder body\n'),
    } as any);

    // DESIGN.md must land in the user's actual folder, NOT the hidden
    // `.od/projects/<id>` dir.
    expect(result.designMdPath).toBe(path.join(userFolder, 'DESIGN.md'));
    expect(fs.existsSync(result.designMdPath)).toBe(true);
    expect(fs.readFileSync(result.designMdPath, 'utf8')).toBe(
      '# DESIGN.md\nimported folder body\n',
    );
    // The hidden daemon data dir should NOT have a DESIGN.md.
    expect(fs.existsSync(path.join(projectsRoot, PROJECT_ID, 'DESIGN.md'))).toBe(false);
  });

  // PR #832 P1 fix from @lefarcen: network failures (DNS, ECONNREFUSED,
  // fetch TypeError) used to fall through the route's catch-all and surface
  // as 500 INTERNAL. Post-fix they are rewrapped as
  // FinalizeUpstreamError(502, ...) inside the function so the route maps
  // to 502 UPSTREAM_UNAVAILABLE with redacted details.
  it('rewraps fetch network rejection as FinalizeUpstreamError(502)', async () => {
    const { db, projectsRoot, designSystemsRoot } = setupPipeline();

    const networkError = new TypeError('fetch failed');
    (networkError as any).cause = { code: 'ENOTFOUND' };
    const fetchImpl = vi.fn(async () => {
      throw networkError;
    });

    await expect(
      finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
        apiKey: 'sk-test',
        baseUrl: 'https://nonexistent.invalid',
        model: 'claude-opus-4-7',
        fetchImpl: fetchImpl as any,
      } as any),
    ).rejects.toMatchObject({
      name: 'FinalizeUpstreamError',
      status: 502,
    });
  });

  it('rewraps 200 with non-JSON body as FinalizeUpstreamError(502)', async () => {
    const { db, projectsRoot, designSystemsRoot } = setupPipeline();

    // 200 OK with text/html body — response.json() will throw SyntaxError.
    const fetchImpl = vi.fn(async () =>
      new Response('<html>upstream proxy error</html>', {
        status: 200,
        headers: { 'content-type': 'text/html' },
      }),
    );

    await expect(
      finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
        apiKey: 'sk-test',
        baseUrl: 'https://api.anthropic.com',
        model: 'claude-opus-4-7',
        fetchImpl: fetchImpl as any,
      } as any),
    ).rejects.toMatchObject({
      name: 'FinalizeUpstreamError',
      status: 502,
    });
  });

  // PR #974 round 7 (lefarcen P1): the helper used to disable its own
  // timeout when the caller passed a request-abort signal. These two tests
  // pin the AbortSignal.any combination so neither cancel path replaces the
  // other. The `timeoutMs` option exists solely so these tests can exercise
  // the abort path without a 120 s real-time wait or fake-timer chains.
  it('aborts after the helper timeout fires even when caller signal never aborts', async () => {
    const { db, projectsRoot, designSystemsRoot } = setupPipeline({
      designSystemId: 'shadcn',
      designSystemBody: '# shadcn\n',
    });

    let capturedSignal: AbortSignal | undefined;
    const hangingFetch = vi.fn((_url: string, init?: RequestInit) => {
      capturedSignal = init?.signal ?? undefined;
      return new Promise<Response>((_resolve, reject) => {
        capturedSignal?.addEventListener('abort', () => {
          const err = new Error('aborted');
          err.name = 'AbortError';
          reject(err);
        });
      });
    }) as unknown as typeof globalThis.fetch;

    const callerController = new AbortController();
    await expect(
      finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
        apiKey: 'sk-test',
        baseUrl: 'https://api.anthropic.com',
        model: 'claude-opus-4-7',
        fetchImpl: hangingFetch as any,
        signal: callerController.signal,
        timeoutMs: 50,
      } as any),
    ).rejects.toMatchObject({ name: 'AbortError' });

    expect(callerController.signal.aborted).toBe(false);
    expect(capturedSignal?.aborted).toBe(true);
  });

  it('aborts immediately when caller signal aborts before fetch settles', async () => {
    const { db, projectsRoot, designSystemsRoot } = setupPipeline({
      designSystemId: 'shadcn',
      designSystemBody: '# shadcn\n',
    });

    const callerController = new AbortController();
    callerController.abort();

    const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => {
      if (init?.signal?.aborted) {
        const err = new Error('aborted');
        err.name = 'AbortError';
        throw err;
      }
      throw new Error('fetch should not see a non-aborted signal when caller aborted pre-flight');
    }) as unknown as typeof globalThis.fetch;

    await expect(
      finalizeDesignPackage(db, projectsRoot, designSystemsRoot, PROJECT_ID, {
        apiKey: 'sk-test',
        baseUrl: 'https://api.anthropic.com',
        model: 'claude-opus-4-7',
        fetchImpl: fetchImpl as any,
        signal: callerController.signal,
      } as any),
    ).rejects.toMatchObject({ name: 'AbortError' });
  });
});

// HTTP-layer tests for the route handler's validation. Boot the daemon
// once via startServer({ port: 0, returnServer: true }) and send real
// HTTP POSTs. These tests exercise the validation branches the function-
// level tests above cannot reach (validateExternalApiBaseUrl is a
// closure inside startServer, not exported).
describe('POST /api/projects/:id/finalize/anthropic — HTTP-layer validation', () => {
  let server: http.Server;
  let serverBaseUrl: string;

  beforeAll(async () => {
    const { startServer } = await import('../src/server.js');
    const started = (await startServer({ port: 0, returnServer: true })) as unknown as {
      url: string;
      server: http.Server;
    };
    serverBaseUrl = started.url;
    server = started.server;
  });

  afterAll(async () => {
    await new Promise<void>((resolve) => server.close(() => resolve()));
  });

  function postJson(id: string, body: unknown): Promise<Response> {
    return fetch(`${serverBaseUrl}/api/projects/${id}/finalize/anthropic`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(body),
    });
  }

  it('400 BAD_REQUEST when baseUrl is not a valid URL (test #13)', async () => {
    const res = await postJson('p1', {
      apiKey: 'sk-test',
      baseUrl: 'not-a-url',
      model: 'claude-opus-4-7',
    });
    expect(res.status).toBe(400);
    const body = await res.json();
    expect(body.error.code).toBe('BAD_REQUEST');
  });

  it('403 FORBIDDEN when baseUrl points at a private internal IP (test #14)', async () => {
    // validateBaseUrl explicitly allows loopback (for local OpenAI-compatible
    // servers) but blocks private internal IPs (10/8, 172.16/12, 192.168/16,
    // fc00::/7, fe80::/10) — see apps/daemon/src/connectionTest.ts:158-185.
    const res = await postJson('p1', {
      apiKey: 'sk-test',
      baseUrl: 'http://10.0.0.1',
      model: 'claude-opus-4-7',
    });
    expect(res.status).toBe(403);
    const body = await res.json();
    expect(body.error.code).toBe('FORBIDDEN');
  });

  it('400 BAD_REQUEST when apiKey is missing (test #15)', async () => {
    const res = await postJson('p1', {
      // apiKey deliberately omitted
      baseUrl: 'https://api.anthropic.com',
      model: 'claude-opus-4-7',
    });
    expect(res.status).toBe(400);
    const body = await res.json();
    expect(body.error.code).toBe('BAD_REQUEST');
    expect(body.error.message.toLowerCase()).toContain('apikey');
  });

  it('400 BAD_REQUEST when :id contains characters outside the safe-id regex (test #16)', async () => {
    // isSafeId allows only [A-Za-z0-9._-]{1,128}. An id like `bad!id`
    // contains `!` and must be rejected before any DB or filesystem work.
    const res = await postJson('bad!id', {
      apiKey: 'sk-test',
      baseUrl: 'https://api.anthropic.com',
      model: 'claude-opus-4-7',
    });
    expect(res.status).toBe(400);
    const body = await res.json();
    expect(body.error.code).toBe('BAD_REQUEST');
    expect(body.error.message.toLowerCase()).toContain('project id');
  });

});

// Path-traversal regression coverage flagged by @lefarcen on PR #832.
//
// The threat: pre-fix `isSafeId` regex `/^[A-Za-z0-9._-]{1,128}$/` allowed
// pure-dot ids (`.`, `..`, `...`) because `.` is in the character class.
// `projectDir` and `resolveProjectDir` both delegated to `isSafeId` so they
// inherited the hole; an id of `..` would resolve to the PARENT of
// `.od/projects/` via `path.join`. The HTTP layer happens to reject this
// today because Express normalizes `%2e%2e` to `..` and collapses the
// path before the route handler sees it (yielding 404), but a direct CLI
// or scripted caller would still reach the function and trigger the
// traversal — and a stored project row whose id is `..` would also slip
// past `isSafeId` checks downstream of the handler.
//
// Unit-test isSafeId directly so the hole stays closed regardless of
// which call site is exercised.
describe('isSafeId — path-traversal regression', () => {
  it('rejects a single dot', () => {
    expect(isSafeId('.')).toBe(false);
  });
  it('rejects a double dot (parent-traversal)', () => {
    expect(isSafeId('..')).toBe(false);
  });
  it('rejects three or more dots', () => {
    expect(isSafeId('...')).toBe(false);
    expect(isSafeId('....')).toBe(false);
  });
  it('rejects characters outside [A-Za-z0-9._-]', () => {
    expect(isSafeId('bad!id')).toBe(false);
    expect(isSafeId('a/b')).toBe(false);
    expect(isSafeId('a\\b')).toBe(false);
    expect(isSafeId(' leading-space')).toBe(false);
  });
  it('rejects empty string and >128 chars', () => {
    expect(isSafeId('')).toBe(false);
    expect(isSafeId('a'.repeat(129))).toBe(false);
  });
  it('rejects non-string inputs', () => {
    expect(isSafeId(null as any)).toBe(false);
    expect(isSafeId(undefined as any)).toBe(false);
    expect(isSafeId(42 as any)).toBe(false);
  });
  it('accepts valid ids including dots in the middle', () => {
    expect(isSafeId('project-1')).toBe(true);
    expect(isSafeId('my-project.v2')).toBe(true);
    expect(isSafeId('818cf7a8-8399-4220-a507-07802d8842a8')).toBe(true);
    expect(isSafeId('a')).toBe(true);
    expect(isSafeId('a.b.c')).toBe(true); // mixed-content with dots is fine
  });
});
