import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import {
  buildFeedbackPayload,
  buildTracePayload,
  deriveLangfuseDeliveryState,
  readLangfuseConfig,
  readTelemetrySinkConfig,
  reportRunCompleted,
  reportRunFeedback,
  type FeedbackReportContext,
  type LangfuseConfig,
  type ReportContext,
  type TelemetrySinkConfig,
} from '../src/langfuse-trace.js';
import { buildPromptStackTelemetry } from '../src/prompt-telemetry.js';

function makeCtx(overrides: Partial<ReportContext> = {}): ReportContext {
  const base: ReportContext = {
    installationId: 'install-uuid-1',
    projectId: 'proj-1',
    conversationId: 'conv-uuid-aaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
    agentId: 'claude',
    run: {
      runId: 'run-1',
      status: 'succeeded',
      startedAt: 1_700_000_000_000,
      endedAt: 1_700_000_004_500,
    },
    message: {
      messageId: 'msg-1',
      prompt: 'Make a landing page for a coffee shop.',
      output: 'Here is a landing page draft …',
      usage: {
        inputTokens: 1234,
        inputTokensProvider: 1234,
        inputTokensEffective: 1484,
        outputTokens: 567,
        totalTokens: 2051,
        cacheReadInputTokens: 200,
        cacheCreationInputTokens: 50,
        uncachedInputTokens: 1234,
        estimatedContextTokens: 1350,
        cacheHitRatio: 0.1347708894878706,
        cacheTokenSource: 'anthropic',
      },
    },
    artifacts: [],
    tools: [
      {
        id: 'tool-1',
        name: 'Bash',
        startedAt: 1_700_000_001_000,
        endedAt: 1_700_000_001_800,
        input: '{"command":"ls -la"}',
        output: 'total 0',
      },
      {
        id: 'tool-2',
        name: 'Write',
        startedAt: 1_700_000_002_000,
        endedAt: 1_700_000_002_900,
        input: '{"path":"index.html"}',
        output: 'wrote index.html',
      },
    ],
    eventsSummary: { toolCalls: 2, errors: 0, durationMs: 4500 },
    prefs: { metrics: true, content: false, artifactManifest: false },
  };
  return { ...base, ...overrides };
}

const TEST_CONFIG: LangfuseConfig = {
  authHeader: 'Basic dGVzdA==',
  baseUrl: 'https://us.cloud.langfuse.com',
  timeoutMs: 20_000,
  retries: 0,
};

function bodyOf(
  batch: unknown[],
  type: string,
  name?: string,
): Record<string, any> {
  const event = (batch as Array<{ type: string; body: Record<string, any> }>).find(
    (item) => item.type === type && (name === undefined || item.body.name === name),
  );
  expect(event).toBeTruthy();
  return event!.body;
}

describe('readLangfuseConfig', () => {
  it('returns null when keys are missing', () => {
    expect(readLangfuseConfig({})).toBeNull();
    expect(readLangfuseConfig({ LANGFUSE_PUBLIC_KEY: 'pk' })).toBeNull();
    expect(readLangfuseConfig({ LANGFUSE_SECRET_KEY: 'sk' })).toBeNull();
  });

  it('returns null when keys are whitespace-only', () => {
    expect(
      readLangfuseConfig({
        LANGFUSE_PUBLIC_KEY: '   ',
        LANGFUSE_SECRET_KEY: 'sk',
      }),
    ).toBeNull();
  });

  it('builds Basic auth header from public:secret', () => {
    const cfg = readLangfuseConfig({
      LANGFUSE_PUBLIC_KEY: 'pk-lf-abc',
      LANGFUSE_SECRET_KEY: 'sk-lf-xyz',
    });
    expect(cfg).not.toBeNull();
    const expected =
      'Basic ' + Buffer.from('pk-lf-abc:sk-lf-xyz').toString('base64');
    expect(cfg!.authHeader).toBe(expected);
  });

  it('uses default US base URL when LANGFUSE_BASE_URL is absent', () => {
    const cfg = readLangfuseConfig({
      LANGFUSE_PUBLIC_KEY: 'pk',
      LANGFUSE_SECRET_KEY: 'sk',
    });
    expect(cfg!.baseUrl).toBe('https://us.cloud.langfuse.com');
  });

  it('honours LANGFUSE_BASE_URL and strips trailing slashes', () => {
    const cfg = readLangfuseConfig({
      LANGFUSE_PUBLIC_KEY: 'pk',
      LANGFUSE_SECRET_KEY: 'sk',
      LANGFUSE_BASE_URL: 'https://cloud.langfuse.com//',
    });
    expect(cfg!.baseUrl).toBe('https://cloud.langfuse.com');
  });

  it('reads optional timeout and retry tuning from env', () => {
    const cfg = readLangfuseConfig({
      LANGFUSE_PUBLIC_KEY: 'pk',
      LANGFUSE_SECRET_KEY: 'sk',
      LANGFUSE_TIMEOUT_MS: '45000',
      LANGFUSE_RETRIES: '2',
    });
    expect(cfg!.timeoutMs).toBe(45_000);
    expect(cfg!.retries).toBe(2);
  });

  it('falls back when timeout and retry env values are invalid', () => {
    const cfg = readLangfuseConfig({
      LANGFUSE_PUBLIC_KEY: 'pk',
      LANGFUSE_SECRET_KEY: 'sk',
      LANGFUSE_TIMEOUT_MS: '-1',
      LANGFUSE_RETRIES: '-2',
    });
    expect(cfg!.timeoutMs).toBe(20_000);
    expect(cfg!.retries).toBe(1);
  });
});

describe('readTelemetrySinkConfig', () => {
  it('prefers the Open Design telemetry relay when configured', () => {
    const cfg = readTelemetrySinkConfig({
      OPEN_DESIGN_TELEMETRY_RELAY_URL: 'https://telemetry.open-design.ai/api/langfuse//',
      LANGFUSE_PUBLIC_KEY: 'pk',
      LANGFUSE_SECRET_KEY: 'sk',
    });
    expect(cfg).toEqual({
      kind: 'relay',
      relayUrl: 'https://telemetry.open-design.ai/api/langfuse',
      timeoutMs: 20_000,
      retries: 1,
    });
  });

  it('uses relay-specific timeout and retry tuning when present', () => {
    const cfg = readTelemetrySinkConfig({
      OPEN_DESIGN_TELEMETRY_RELAY_URL: 'https://telemetry.open-design.ai/api/langfuse',
      OPEN_DESIGN_TELEMETRY_TIMEOUT_MS: '30000',
      OPEN_DESIGN_TELEMETRY_RETRIES: '3',
      LANGFUSE_TIMEOUT_MS: '1',
      LANGFUSE_RETRIES: '0',
    });
    expect(cfg).toMatchObject({
      kind: 'relay',
      timeoutMs: 30_000,
      retries: 3,
    });
  });

  it('falls back to direct Langfuse config for local smoke tests', () => {
    const cfg = readTelemetrySinkConfig({
      LANGFUSE_PUBLIC_KEY: 'pk',
      LANGFUSE_SECRET_KEY: 'sk',
    });
    expect(cfg).toMatchObject({
      kind: 'langfuse',
      baseUrl: 'https://us.cloud.langfuse.com',
    });
  });
});

describe('deriveLangfuseDeliveryState', () => {
  it('marks traces not expected when metrics consent is off', () => {
    expect(
      deriveLangfuseDeliveryState(
        { metrics: false, content: true, artifactManifest: true },
        { kind: 'langfuse', ...TEST_CONFIG },
      ),
    ).toEqual({
      langfuse_expected: false,
      langfuse_delivery_status: 'not_expected',
      langfuse_drop_reason: 'metrics_consent_off',
    });
  });

  it('marks traces not expected when content consent is off', () => {
    expect(
      deriveLangfuseDeliveryState(
        { metrics: true, content: false, artifactManifest: true },
        { kind: 'langfuse', ...TEST_CONFIG },
      ),
    ).toEqual({
      langfuse_expected: false,
      langfuse_delivery_status: 'not_expected',
      langfuse_drop_reason: 'content_consent_off',
    });
  });

  it('marks traces not expected when no sink is configured', () => {
    expect(
      deriveLangfuseDeliveryState(
        { metrics: true, content: true, artifactManifest: true },
        null,
      ),
    ).toEqual({
      langfuse_expected: false,
      langfuse_delivery_status: 'not_expected',
      langfuse_drop_reason: 'missing_sink_config',
    });
  });

  it('marks eligible traces as queued at run-finished time', () => {
    expect(
      deriveLangfuseDeliveryState(
        { metrics: true, content: true, artifactManifest: true },
        { kind: 'langfuse', ...TEST_CONFIG },
      ),
    ).toEqual({
      langfuse_expected: true,
      langfuse_delivery_status: 'queued',
    });
  });
});

describe('buildTracePayload', () => {
  it('emits a trace with nested agent + generation observations', () => {
    const batch = buildTracePayload(makeCtx());
    const types = (batch as Array<{ type: string }>).map((e) => e.type);
    expect(types).toEqual([
      'trace-create',
      'span-create',
      'generation-create',
      'span-create',
      'span-create',
    ]);
    const span = bodyOf(batch, 'span-create', 'agent-run');
    const gen = bodyOf(batch, 'generation-create', 'llm');
    const bash = bodyOf(batch, 'span-create', 'tool:Bash');
    const write = bodyOf(batch, 'span-create', 'tool:Write');
    expect(span.id).toBe('run-1-agent');
    expect(span.traceId).toBe('run-1');
    expect(gen.traceId).toBe('run-1');
    expect(gen.parentObservationId).toBe('run-1-agent');
    expect(bash.parentObservationId).toBe('run-1-agent');
    expect(bash.input).toBeUndefined();
    expect(bash.output).toBeUndefined();
    expect(bash.metadata.toolName).toBe('Bash');
    expect(write.parentObservationId).toBe('run-1-agent');
  });

  it('omits prompt + output when content gate is off', () => {
    const batch = buildTracePayload(makeCtx());
    const trace = (batch[0] as any).body;
    const span = bodyOf(batch, 'span-create', 'agent-run');
    const gen = bodyOf(batch, 'generation-create', 'llm');
    const tool = bodyOf(batch, 'span-create', 'tool:Bash');
    expect(trace.input).toBeUndefined();
    expect(trace.output).toBeUndefined();
    expect(span.input).toBeUndefined();
    expect(span.output).toBeUndefined();
    expect(gen.input).toBeUndefined();
    expect(gen.output).toBeUndefined();
    expect(tool.input).toBeUndefined();
    expect(tool.output).toBeUndefined();
  });

  it('includes prompt + output when content gate is on', () => {
    const batch = buildTracePayload(
      makeCtx({
        prefs: { metrics: true, content: true, artifactManifest: false },
      }),
    );
    const trace = (batch[0] as any).body;
    const tool = bodyOf(batch, 'span-create', 'tool:Bash');
    expect(trace.input).toMatch(/coffee shop/);
    expect(trace.output).toMatch(/landing page draft/);
    expect(tool.input).toMatch(/ls -la/);
    expect(tool.output).toBe('total 0');
  });

  it('adds prompt-stack metadata to trace and generation without replacing user prompt input', () => {
    const promptTelemetry = buildPromptStackTelemetry({
      composedPrompt:
        '# Instructions\n\nWork in /Users/alice/project\n\n---\n# User request\n\nBuild a card',
      sections: [
        { kind: 'daemonSystemPrompt', content: 'Work in /Users/alice/project' },
        { kind: 'userRequest', content: 'Build a card' },
        { kind: 'attachments', metadata: ['src/App.tsx'] },
      ],
    });
    const batch = buildTracePayload(
      makeCtx({
        prefs: { metrics: true, content: true, artifactManifest: false },
        promptTelemetry,
      }),
    );

    const trace = bodyOf(batch, 'trace-create');
    const generation = bodyOf(batch, 'generation-create', 'llm');
    expect(trace.input).toBe('Make a landing page for a coffee shop.');
    expect(generation.input).toMatchObject({
      type: 'open-design.prompt-stack',
      redactionVersion: 'prompt-stack-redaction-v1',
      sectionCount: 3,
      sections: [
        expect.objectContaining({
          kind: 'daemonSystemPrompt',
          redactedContent: expect.stringContaining('[REDACTED:path]'),
        }),
        expect.objectContaining({
          kind: 'userRequest',
          redactedContent: 'Build a card',
        }),
        expect.objectContaining({
          kind: 'attachments',
          contentMode: 'metadata-only',
        }),
      ],
    });
    expect(trace.metadata.promptStack).toMatchObject({
      redactionVersion: 'prompt-stack-redaction-v1',
      sectionCount: 3,
    });
    expect(generation.metadata.promptStack).toEqual(trace.metadata.promptStack);
    expect(trace.metadata.promptStack.sections[0].redactedContent).toContain(
      '[REDACTED:path]',
    );
    expect(trace.metadata.promptStack.sections[2].redactedContent).toBeUndefined();
    expect(trace.metadata.promptStack_section_daemonSystemPrompt_present).toBeUndefined();
    expect(trace.metadata.promptStack_section_attachments_present).toBeUndefined();
    expect(trace.metadata.promptStack_section_daemonSystemPrompt_rawBytes).toBeUndefined();
    expect(trace.metadata.promptStack_promptFingerprint).toMatch(/^sha256:/);
  });

  it('omits prompt-stack redactedContent when metrics or content consent is off', () => {
    const promptTelemetry = buildPromptStackTelemetry({
      composedPrompt: '# User request\n\nBuild a card',
      sections: [{ kind: 'userRequest', content: 'Build a card' }],
    });

    for (const prefs of [
      { metrics: true, content: false, artifactManifest: false },
      { metrics: false, content: true, artifactManifest: false },
    ]) {
      const batch = buildTracePayload(makeCtx({ prefs, promptTelemetry }));
      const trace = bodyOf(batch, 'trace-create');
      const generation = bodyOf(batch, 'generation-create', 'llm');
      expect(trace.input).toBeUndefined();
      expect(trace.metadata.promptStack.sections[0].redactedContent).toBeUndefined();
      expect(trace.metadata.promptStack.redactedContentBytes).toBe(0);
      expect(generation.input).toMatchObject({
        type: 'open-design.prompt-stack',
        redactedContentBytes: 0,
        sections: [expect.not.objectContaining({ redactedContent: expect.any(String) })],
      });
    }
  });

  it('truncates ASCII prompt at 8 KB and output at 16 KB (bytes == chars)', () => {
    const longPrompt = 'a'.repeat(20_000);
    const longOutput = 'b'.repeat(40_000);
    const batch = buildTracePayload(
      makeCtx({
        message: {
          messageId: 'msg-1',
          prompt: longPrompt,
          output: longOutput,
        },
        prefs: { metrics: true, content: true, artifactManifest: false },
      }),
    );
    const trace = (batch[0] as any).body;
    expect(Buffer.byteLength(trace.input, 'utf8')).toBe(8 * 1024);
    expect(Buffer.byteLength(trace.output, 'utf8')).toBe(16 * 1024);
  });

  it('truncates by UTF-8 bytes, not by JS string length, for multi-byte text', () => {
    // Each CJK character is 3 bytes in UTF-8 but 1 unit in String.length.
    // 4096 chars × 3 bytes = 12_288 bytes, well over the 8 KB input cap.
    const longCJK = '设'.repeat(4096);
    expect(longCJK.length).toBe(4096);
    expect(Buffer.byteLength(longCJK, 'utf8')).toBe(12_288);
    const batch = buildTracePayload(
      makeCtx({
        message: { messageId: 'msg-1', prompt: longCJK, output: '' },
        prefs: { metrics: true, content: true, artifactManifest: false },
      }),
    );
    const trace = (batch[0] as any).body;
    expect(Buffer.byteLength(trace.input, 'utf8')).toBeLessThanOrEqual(8 * 1024);
    // Boundary safety: the trimmed result must still be valid UTF-8 (no
    // half-encoded characters). Round-tripping through Buffer should be
    // lossless if the cut landed correctly.
    expect(Buffer.from(trace.input as string, 'utf8').toString('utf8')).toBe(
      trace.input,
    );
    // And every character is still '设', i.e. we didn't mangle the encoding.
    expect(/^设+$/.test(trace.input as string)).toBe(true);
  });

  it('omits artifacts when manifest gate is off', () => {
    const batch = buildTracePayload(
      makeCtx({
        artifacts: [
          { slug: 'a', type: 'html', sizeBytes: 100 },
          { slug: 'b', type: 'jsx', sizeBytes: 200 },
        ],
      }),
    );
    const trace = (batch[0] as any).body;
    expect(trace.metadata.artifacts).toBeUndefined();
    expect(trace.metadata.artifactsTruncated).toBeUndefined();
  });

  it('caps artifacts at 50 entries with a truncation flag', () => {
    const many = Array.from({ length: 75 }, (_, i) => ({
      slug: `art-${i}`,
      type: 'html',
      sizeBytes: 1,
    }));
    const batch = buildTracePayload(
      makeCtx({
        artifacts: many,
        prefs: { metrics: true, content: false, artifactManifest: true },
      }),
    );
    const trace = (batch[0] as any).body;
    expect(trace.metadata.artifacts).toHaveLength(50);
    expect(trace.metadata.artifactsTruncated).toBe(true);
  });

  it('keeps eventsSummary metadata regardless of content / artifact gates', () => {
    const batch = buildTracePayload(makeCtx());
    const trace = (batch[0] as any).body;
    expect(trace.metadata.eventsSummary).toEqual({
      toolCalls: 2,
      errors: 0,
      durationMs: 4500,
    });
  });

  it('records token counts in metadata.tokens and generation.usage', () => {
    const batch = buildTracePayload(makeCtx());
    const trace = (batch[0] as any).body;
    const gen = bodyOf(batch, 'generation-create', 'llm');
    expect(trace.metadata.tokens).toEqual({
      input: 1234,
      inputProvider: 1234,
      inputEffective: 1484,
      output: 567,
      total: 2051,
      cacheReadInput: 200,
      cacheCreationInput: 50,
      uncachedInput: 1234,
      estimatedContext: 1350,
      cacheHitRatio: 0.1347708894878706,
      cacheTokenSource: 'anthropic',
    });
    expect(gen.usage).toEqual({
      input: 1484,
      output: 567,
      total: 2051,
      unit: 'TOKENS',
    });
  });

  it('uses conversationId as sessionId when within length limit', () => {
    const batch = buildTracePayload(makeCtx());
    expect((batch[0] as any).body.sessionId).toBe(
      'conv-uuid-aaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
    );
  });

  it('drops sessionId when conversationId exceeds 200 chars', () => {
    const batch = buildTracePayload(
      makeCtx({ conversationId: 'x'.repeat(201) }),
    );
    expect((batch[0] as any).body.sessionId).toBeUndefined();
  });

  it('builds tag list with project + agent + extras', () => {
    const batch = buildTracePayload(
      makeCtx({ extraTags: ['legacy:tag'] }),
    );
    expect((batch[0] as any).body.tags).toEqual([
      'open-design',
      'project:proj-1',
      'agent:claude',
      'legacy:tag',
    ]);
  });

  it('adds turn-level tags (model / skill / DS) and runtime tags (os / client)', () => {
    const batch = buildTracePayload(
      makeCtx({
        turn: {
          model: 'gpt-4o',
          reasoning: 'high',
          skillId: 'landing-page',
          designSystemId: 'mission-control',
        },
        runtime: {
          os: 'darwin',
          arch: 'arm64',
          nodeVersion: 'v22.22.0',
          appVersion: '0.5.0',
          clientType: 'desktop',
        },
      }),
    );
    expect((batch[0] as any).body.tags).toEqual([
      'open-design',
      'project:proj-1',
      'agent:claude',
      'model:gpt-4o',
      'skill:landing-page',
      'ds:mission-control',
      'os:darwin',
      'client:desktop',
    ]);
  });

  it('promotes model + reasoning to first-class generation fields', () => {
    const batch = buildTracePayload(
      makeCtx({
        turn: { model: 'claude-sonnet-4-5', reasoning: 'high' },
      }),
    );
    const gen = bodyOf(batch, 'generation-create', 'llm');
    expect(gen.model).toBe('claude-sonnet-4-5');
    expect(gen.modelParameters).toEqual({ reasoning: 'high' });
  });

  it('omits modelParameters entirely when reasoning is unset', () => {
    const batch = buildTracePayload(
      makeCtx({ turn: { model: 'gpt-4o' } }),
    );
    const gen = bodyOf(batch, 'generation-create', 'llm');
    expect(gen.model).toBe('gpt-4o');
    expect(gen.modelParameters).toBeUndefined();
  });

  it('mirrors runtime + turn fields into trace metadata for query / export', () => {
    const batch = buildTracePayload(
      makeCtx({
        turn: { model: 'claude-sonnet-4-5', skillId: 'landing-page' },
        runtime: {
          os: 'linux',
          arch: 'x64',
          nodeVersion: 'v22.22.0',
          appVersion: '0.5.0',
          appChannel: 'beta',
          packaged: true,
          clientType: 'web',
        },
      }),
    );
    const m = (batch[0] as any).body.metadata;
    expect(m.model).toBe('claude-sonnet-4-5');
    expect(m.skillId).toBe('landing-page');
    expect(m.os).toBe('linux');
    expect(m.arch).toBe('x64');
    expect(m.nodeVersion).toBe('v22.22.0');
    expect(m.appVersion).toBe('0.5.0');
    expect(m.appChannel).toBe('beta');
    expect(m.packaged).toBe(true);
    expect(m.clientType).toBe('web');
    expect(m.projectId).toBe('proj-1');
    expect(m.agent).toBe('claude');
  });

  it('marks generation.level=ERROR when run failed', () => {
    const batch = buildTracePayload(
      makeCtx({
        run: {
          runId: 'run-1',
          status: 'failed',
          startedAt: 1,
          endedAt: 2,
          error: 'boom',
        },
      }),
    );
    const span = bodyOf(batch, 'span-create', 'agent-run');
    const gen = bodyOf(batch, 'generation-create', 'llm');
    expect(gen.level).toBe('ERROR');
    expect(gen.statusMessage).toBe('boom');
    expect(span.level).toBe('ERROR');
    expect(span.statusMessage).toBe('boom');
    expect(bodyOf(batch, 'event-create', 'run-error').statusMessage).toBe('boom');
    expect((batch[0] as any).body.metadata.error).toBe('boom');
    expect((batch[0] as any).body.metadata.success).toBe(false);
  });

  it('mirrors structured failure fields into trace metadata', () => {
    const batch = buildTracePayload(
      makeCtx({
        run: {
          runId: 'run-rate-limit',
          status: 'failed',
          startedAt: 1,
          endedAt: 2,
          error: 'session limit reached',
          errorCode: 'RATE_LIMITED',
          failure: {
            failure_category: 'rate_limit',
            failure_detail: 'hard_quota',
            failure_stage: 'session_init',
            retryable: false,
            user_action: 'none',
          },
        },
      }),
    );
    const metadata = (batch[0] as any).body.metadata;
    expect(metadata.error_code).toBe('RATE_LIMITED');
    expect(metadata.langfuse_trace_id).toBe('run-rate-limit');
    expect(metadata.langfuse_expected).toBe(false);
    expect(metadata.langfuse_delivery_status).toBe('not_expected');
    expect(metadata.langfuse_drop_reason).toBe('content_consent_off');
    expect(metadata.failure_category).toBe('rate_limit');
    expect(metadata.failure_detail).toBe('hard_quota');
    expect(metadata.failure_stage).toBe('session_init');
    expect(metadata.retryable).toBe(false);
    expect(metadata.user_action).toBe('none');
  });

  it('mirrors run timing fields into trace metadata', () => {
    const batch = buildTracePayload(
      makeCtx({
        run: {
          runId: 'run-timing',
          status: 'succeeded',
          startedAt: 1,
          endedAt: 2,
          timings: {
            queue_duration_ms: 10,
            pre_spawn_duration_ms: 20,
            process_spawn_duration_ms: 30,
            time_to_first_token_ms: 40,
            spawn_to_first_token_ms: 50,
            generation_duration_ms: 60,
            tool_call_count: 2,
            tool_duration_ms: 70,
            finalize_duration_ms: 5,
            total_duration_ms: 100,
          },
        },
      }),
    );
    const metadata = (batch[0] as any).body.metadata;
    expect(metadata.queue_duration_ms).toBe(10);
    expect(metadata.process_spawn_duration_ms).toBe(30);
    expect(metadata.time_to_first_token_ms).toBe(40);
    expect(metadata.tool_call_count).toBe(2);
    expect(metadata.total_duration_ms).toBe(100);
  });

  it('adds duration spans for run timing marks', () => {
    const batch = buildTracePayload(
      makeCtx({
        run: {
          runId: 'run-spans',
          status: 'succeeded',
          startedAt: 1_700_000_000_000,
          endedAt: 1_700_000_004_500,
          timingMarks: {
            startChatRunStartedAt: 1_700_000_000_100,
            promptBuildStartAt: 1_700_000_000_200,
            promptBuildEndAt: 1_700_000_000_260,
            processSpawnStartedAt: 1_700_000_000_300,
            processSpawnedAt: 1_700_000_000_380,
            modelCallStartAt: 1_700_000_000_420,
            firstTokenAt: 1_700_000_001_000,
            finalizeStartAt: 1_700_000_004_200,
          },
        },
      }),
    );

    const spans = (batch as any[])
      .filter((item) => item.type === 'span-create')
      .map((item) => item.body);
    expect(spans.map((span) => span.name)).toEqual(
      expect.arrayContaining([
        'queue',
        'prompt-build',
        'spawn',
        'model-call',
        'stream-output',
        'finalize',
      ]),
    );
    expect(bodyOf(batch, 'span-create', 'spawn')).toMatchObject({
      id: 'run-spans-phase-spawn',
      parentObservationId: 'run-spans-gen',
      metadata: {
        durationMs: 80,
        boundary: 'processSpawnStartedAt -> processSpawnedAt',
      },
    });
    expect(bodyOf(batch, 'span-create', 'tool:Bash').parentObservationId).toBe(
      'run-spans-phase-model-call',
    );
  });

  it('passes through anonymous installationId as userId', () => {
    const batch = buildTracePayload(makeCtx({ installationId: null }));
    expect((batch[0] as any).body.userId).toBeUndefined();
  });
});

describe('reportRunCompleted', () => {
  let warnSpy: ReturnType<typeof vi.spyOn>;

  beforeEach(() => {
    warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
  });

  afterEach(() => {
    warnSpy.mockRestore();
    vi.restoreAllMocks();
  });

  it('does nothing when metrics gate is off', async () => {
    const fetchSpy = vi.fn();
    const result = await reportRunCompleted(
      makeCtx({
        prefs: { metrics: false, content: true, artifactManifest: true },
      }),
      { config: TEST_CONFIG, fetchImpl: fetchSpy as any },
    );
    expect(fetchSpy).not.toHaveBeenCalled();
    expect(result).toEqual({
      langfuse_expected: false,
      langfuse_delivery_status: 'not_expected',
      langfuse_drop_reason: 'metrics_consent_off',
    });
  });

  it('does nothing when content gate is off', async () => {
    const fetchSpy = vi.fn();
    const result = await reportRunCompleted(
      makeCtx({
        prefs: { metrics: true, content: false, artifactManifest: true },
      }),
      { config: TEST_CONFIG, fetchImpl: fetchSpy as any },
    );
    expect(fetchSpy).not.toHaveBeenCalled();
    expect(result).toEqual({
      langfuse_expected: false,
      langfuse_delivery_status: 'not_expected',
      langfuse_drop_reason: 'content_consent_off',
    });
  });

  it('does nothing when no Langfuse config is available', async () => {
    const fetchSpy = vi.fn();
    const result = await reportRunCompleted(
      makeCtx({
        prefs: { metrics: true, content: true, artifactManifest: false },
      }),
      {
        config: null,
        fetchImpl: fetchSpy as any,
      },
    );
    expect(fetchSpy).not.toHaveBeenCalled();
    expect(result).toEqual({
      langfuse_expected: false,
      langfuse_delivery_status: 'not_expected',
      langfuse_drop_reason: 'missing_sink_config',
    });
  });

  it('POSTs to /api/public/ingestion with Basic auth and a JSON batch body', async () => {
    const fetchSpy = vi.fn().mockResolvedValue(
      new Response('{}', { status: 200 }),
    );
    const result = await reportRunCompleted(
      makeCtx({
        prefs: { metrics: true, content: true, artifactManifest: false },
      }),
      {
        config: TEST_CONFIG,
        fetchImpl: fetchSpy as any,
      },
    );
    expect(fetchSpy).toHaveBeenCalledTimes(1);
    const call = fetchSpy.mock.calls[0]!;
    const url = call[0] as string;
    const init = call[1] as RequestInit & { headers: Record<string, string> };
    expect(url).toBe('https://us.cloud.langfuse.com/api/public/ingestion');
    expect(init.method).toBe('POST');
    expect(init.headers.Authorization).toBe('Basic dGVzdA==');
    expect(init.headers['Content-Type']).toBe('application/json');
    const body = JSON.parse(init.body as string);
    expect(Array.isArray(body.batch)).toBe(true);
    expect(body.batch.map((item: any) => item.type)).toEqual([
      'trace-create',
      'span-create',
      'generation-create',
      'span-create',
      'span-create',
    ]);
    expect(result).toEqual({
      langfuse_expected: true,
      langfuse_delivery_status: 'accepted',
    });
  });

  it('keeps stderr out of trace input/output and stores only a redacted metadata tail', () => {
    const batch = buildTracePayload(
      makeCtx({
        prefs: { metrics: true, content: true, artifactManifest: false },
        run: {
          runId: 'run-err',
          status: 'failed',
          startedAt: 1_700_000_000_000,
          endedAt: 1_700_000_004_500,
          error: 'provider failed',
          stderr: {
            tail: 'HTTP 429 OPENAI_API_KEY=[REDACTED:openai_key]',
            lineCount: 12,
            truncated: true,
          },
        },
      }),
    ) as any[];

    const trace = bodyOf(batch, 'trace-create');
    const generation = bodyOf(batch, 'generation-create', 'llm');

    expect(trace.input).toBe('Make a landing page for a coffee shop.');
    expect(trace.output).toBe('Here is a landing page draft …');
    expect(generation.input).toBe('Make a landing page for a coffee shop.');
    expect(generation.output).toBe('Here is a landing page draft …');
    expect(trace.metadata.stderr).toEqual({
      tail: 'HTTP 429 OPENAI_API_KEY=[REDACTED:openai_key]',
      lineCount: 12,
      truncated: true,
    });
    expect(JSON.stringify(batch)).not.toContain('sk-raw');
  });

  it('POSTs serialized ingestion batches to the Open Design telemetry relay', async () => {
    const relayConfig: TelemetrySinkConfig = {
      kind: 'relay',
      relayUrl: 'https://telemetry.open-design.ai/api/langfuse',
      timeoutMs: 20_000,
      retries: 0,
    };
    const fetchSpy = vi.fn().mockResolvedValue(
      new Response('{}', { status: 200 }),
    );
    const result = await reportRunCompleted(
      makeCtx({
        prefs: { metrics: true, content: true, artifactManifest: false },
      }),
      {
        config: relayConfig,
        fetchImpl: fetchSpy as any,
      },
    );
    expect(fetchSpy).toHaveBeenCalledTimes(1);
    const call = fetchSpy.mock.calls[0]!;
    const url = call[0] as string;
    const init = call[1] as RequestInit & { headers: Record<string, string> };
    expect(url).toBe('https://telemetry.open-design.ai/api/langfuse');
    expect(init.method).toBe('POST');
    expect(init.headers.Authorization).toBeUndefined();
    expect(init.headers['Content-Type']).toBe('application/json');
    expect(init.headers['X-Open-Design-Telemetry']).toBe('langfuse-ingestion-v1');
    const body = JSON.parse(init.body as string);
    expect(Array.isArray(body.batch)).toBe(true);
    expect(result).toEqual({
      langfuse_expected: true,
      langfuse_delivery_status: 'accepted',
    });
  });

  it('warns when the relay returns per-event errors', async () => {
    const relayConfig: TelemetrySinkConfig = {
      kind: 'relay',
      relayUrl: 'https://telemetry.open-design.ai/api/langfuse',
      timeoutMs: 20_000,
      retries: 0,
    };
    const fetchSpy = vi.fn().mockResolvedValue(
      new Response(
        JSON.stringify({ successes: [], errors: [{ id: 'bad', status: 400 }] }),
        { status: 207 },
      ),
    );
    const result = await reportRunCompleted(
      makeCtx({
        prefs: { metrics: true, content: true, artifactManifest: false },
      }),
      {
        config: relayConfig,
        fetchImpl: fetchSpy as any,
      },
    );
    expect(warnSpy).toHaveBeenCalledWith(
      expect.stringContaining('Relay per-event errors (1)'),
    );
    expect(result).toEqual({
      langfuse_expected: true,
      langfuse_delivery_status: 'failed',
      langfuse_drop_reason: 'langfuse_4xx',
    });
  });


  it('classifies relay 413 responses as relay_413', async () => {
    const relayConfig: TelemetrySinkConfig = {
      kind: 'relay',
      relayUrl: 'https://telemetry.open-design.ai/api/langfuse',
      timeoutMs: 20_000,
      retries: 0,
    };
    const fetchSpy = vi.fn().mockResolvedValue(
      new Response('payload too large', { status: 413 }),
    );
    const result = await reportRunCompleted(
      makeCtx({
        prefs: { metrics: true, content: true, artifactManifest: false },
      }),
      {
        config: relayConfig,
        fetchImpl: fetchSpy as any,
      },
    );
    expect(result).toEqual({
      langfuse_expected: true,
      langfuse_delivery_status: 'failed',
      langfuse_drop_reason: 'relay_413',
    });
  });

  it('classifies relay 5xx responses as relay_5xx', async () => {
    const relayConfig: TelemetrySinkConfig = {
      kind: 'relay',
      relayUrl: 'https://telemetry.open-design.ai/api/langfuse',
      timeoutMs: 20_000,
      retries: 0,
    };
    const fetchSpy = vi.fn().mockResolvedValue(
      new Response('upstream unavailable', { status: 503 }),
    );
    const result = await reportRunCompleted(
      makeCtx({
        prefs: { metrics: true, content: true, artifactManifest: false },
      }),
      {
        config: relayConfig,
        fetchImpl: fetchSpy as any,
      },
    );
    expect(result).toEqual({
      langfuse_expected: true,
      langfuse_delivery_status: 'failed',
      langfuse_drop_reason: 'relay_5xx',
    });
  });

  it('classifies direct Langfuse 5xx responses as langfuse_5xx', async () => {
    const fetchSpy = vi.fn().mockResolvedValue(
      new Response('server error', { status: 503 }),
    );
    const result = await reportRunCompleted(
      makeCtx({
        prefs: { metrics: true, content: true, artifactManifest: false },
      }),
      {
        config: TEST_CONFIG,
        fetchImpl: fetchSpy as any,
      },
    );
    expect(result).toEqual({
      langfuse_expected: true,
      langfuse_delivery_status: 'failed',
      langfuse_drop_reason: 'langfuse_5xx',
    });
  });

  it('classifies relay per-event 429s separately from generic 4xx', async () => {
    const relayConfig: TelemetrySinkConfig = {
      kind: 'relay',
      relayUrl: 'https://telemetry.open-design.ai/api/langfuse',
      timeoutMs: 20_000,
      retries: 0,
    };
    const fetchSpy = vi.fn().mockResolvedValue(
      new Response(
        JSON.stringify({ successes: [], errors: [{ id: 'throttled', status: 429 }] }),
        { status: 207 },
      ),
    );
    const result = await reportRunCompleted(
      makeCtx({
        prefs: { metrics: true, content: true, artifactManifest: false },
      }),
      {
        config: relayConfig,
        fetchImpl: fetchSpy as any,
      },
    );
    expect(result).toEqual({
      langfuse_expected: true,
      langfuse_delivery_status: 'failed',
      langfuse_drop_reason: 'relay_429',
    });
  });

  it('classifies direct Langfuse per-event 5xx responses as langfuse_5xx', async () => {
    const fetchSpy = vi.fn().mockResolvedValue(
      new Response(
        JSON.stringify({ successes: [], errors: [{ id: 'lf-down', status: 503 }] }),
        { status: 207 },
      ),
    );
    const result = await reportRunCompleted(
      makeCtx({
        prefs: { metrics: true, content: true, artifactManifest: false },
      }),
      {
        config: TEST_CONFIG,
        fetchImpl: fetchSpy as any,
      },
    );
    expect(result).toEqual({
      langfuse_expected: true,
      langfuse_delivery_status: 'failed',
      langfuse_drop_reason: 'langfuse_5xx',
    });
  });

  it('warns and drops when serialized batch exceeds the hard cap', async () => {
    // Per-field truncation already caps prompt/output, so we overflow the
    // hard cap by stuffing 50 artifact entries with very long slugs while
    // artifactManifest is on (50 × 30 KB ≈ 1.5 MB > 1 MB cap).
    const fetchSpy = vi.fn();
    const fatArtifacts = Array.from({ length: 50 }, (_, i) => ({
      slug: 'a'.repeat(30_000) + i,
      type: 'html',
      sizeBytes: 1,
    }));
    const result = await reportRunCompleted(
      makeCtx({
        artifacts: fatArtifacts,
        prefs: { metrics: true, content: true, artifactManifest: true },
      }),
      { config: TEST_CONFIG, fetchImpl: fetchSpy as any },
    );
    expect(fetchSpy).not.toHaveBeenCalled();
    expect(warnSpy).toHaveBeenCalledWith(
      expect.stringContaining('Batch too large'),
    );
    expect(result).toEqual({
      langfuse_expected: true,
      langfuse_delivery_status: 'failed',
      langfuse_drop_reason: 'payload_too_large',
    });
  });

  it('only warns (does not throw) when fetch rejects', async () => {
    const fetchSpy = vi.fn().mockRejectedValue(new Error('network down'));
    await expect(
      reportRunCompleted(
        makeCtx({
          prefs: { metrics: true, content: true, artifactManifest: false },
        }),
        {
          config: TEST_CONFIG,
          fetchImpl: fetchSpy as any,
        },
      ),
    ).resolves.toEqual({
      langfuse_expected: true,
      langfuse_delivery_status: 'failed',
      langfuse_drop_reason: 'network_error',
    });
    expect(warnSpy).toHaveBeenCalledWith(
      expect.stringContaining('Fetch error'),
    );
  });

  it('retries once when fetch rejects before warning', async () => {
    const fetchSpy = vi
      .fn()
      .mockRejectedValueOnce(new Error('timeout'))
      .mockResolvedValueOnce(new Response('{}', { status: 207 }));
    const result = await reportRunCompleted(
      makeCtx({
        prefs: { metrics: true, content: true, artifactManifest: false },
      }),
      {
        config: { ...TEST_CONFIG, retries: 1 },
        fetchImpl: fetchSpy as any,
      },
    );
    expect(fetchSpy).toHaveBeenCalledTimes(2);
    expect(warnSpy).not.toHaveBeenCalled();
    expect(result).toEqual({
      langfuse_expected: true,
      langfuse_delivery_status: 'accepted',
    });
  });

  it('only warns (does not throw) when ingestion responds non-2xx', async () => {
    const fetchSpy = vi.fn().mockResolvedValue(
      new Response('rate limited', { status: 429 }),
    );
    const result = await reportRunCompleted(
      makeCtx({
        prefs: { metrics: true, content: true, artifactManifest: false },
      }),
      {
        config: TEST_CONFIG,
        fetchImpl: fetchSpy as any,
      },
    );
    expect(warnSpy).toHaveBeenCalledWith(
      expect.stringContaining('Ingestion failed 429'),
    );
    expect(result).toEqual({
      langfuse_expected: true,
      langfuse_delivery_status: 'failed',
      langfuse_drop_reason: 'langfuse_4xx',
    });
  });

  it('warns when 207 Multi-Status body lists per-event errors', async () => {
    // Langfuse legacy ingestion always responds with 207. response.ok is
    // true, but malformed events show up in body.errors instead of as a
    // top-level non-2xx. Without parsing them they'd be silently dropped.
    const fetchSpy = vi.fn().mockResolvedValue(
      new Response(
        JSON.stringify({
          successes: [{ id: 'a', status: 201 }],
          errors: [
            {
              id: 'b',
              status: 400,
              message: 'invalid generation usage shape',
            },
          ],
        }),
        { status: 207 },
      ),
    );
    const result = await reportRunCompleted(
      makeCtx({
        prefs: { metrics: true, content: true, artifactManifest: false },
      }),
      {
        config: TEST_CONFIG,
        fetchImpl: fetchSpy as any,
      },
    );
    expect(warnSpy).toHaveBeenCalledWith(
      expect.stringContaining('Per-event errors (1)'),
    );
    expect(result).toEqual({
      langfuse_expected: true,
      langfuse_delivery_status: 'failed',
      langfuse_drop_reason: 'langfuse_4xx',
    });
  });

  it('does not warn when 207 body has empty errors array', async () => {
    const fetchSpy = vi.fn().mockResolvedValue(
      new Response(
        JSON.stringify({
          successes: [
            { id: 'a', status: 201 },
            { id: 'b', status: 201 },
          ],
          errors: [],
        }),
        { status: 207 },
      ),
    );
    const result = await reportRunCompleted(
      makeCtx({
        prefs: { metrics: true, content: true, artifactManifest: false },
      }),
      {
        config: TEST_CONFIG,
        fetchImpl: fetchSpy as any,
      },
    );
    expect(warnSpy).not.toHaveBeenCalled();
    expect(result).toEqual({
      langfuse_expected: true,
      langfuse_delivery_status: 'accepted',
    });
  });
});

function makeFeedbackCtx(
  overrides: Partial<FeedbackReportContext> = {},
): FeedbackReportContext {
  return {
    runId: 'run-feedback-1',
    installationId: 'install-uuid-1',
    prefs: { metrics: true, content: true },
    rating: 'positive',
    reasonCodes: ['matched_request'],
    hasCustomReason: false,
    customReason: '',
    ...overrides,
  };
}

describe('buildFeedbackPayload', () => {
  it('emits a numeric user_rating score plus per-reason categorical scores', () => {
    const batch = buildFeedbackPayload(
      makeFeedbackCtx({
        rating: 'negative',
        reasonCodes: ['missed_request', 'weak_visual'],
        hasCustomReason: true,
        customReason: 'It got the layout wrong on tablet',
      }),
    ) as Array<Record<string, any>>;
    expect(batch).toHaveLength(3);
    const ratingScore = batch[0]!;
    expect(ratingScore.type).toBe('score-create');
    expect(ratingScore.body.traceId).toBe('run-feedback-1');
    expect(ratingScore.body.name).toBe('user_rating');
    expect(ratingScore.body.value).toBe(-1);
    expect(ratingScore.body.dataType).toBe('NUMERIC');
    expect(ratingScore.body.comment).toBe('negative');
    expect(ratingScore.body.metadata).toMatchObject({
      reasonCount: 2,
      customReason: 'It got the layout wrong on tablet',
      hasCustomReason: true,
    });
    for (const reasonScore of batch.slice(1)) {
      expect(reasonScore.body.name).toBe('user_rating_reason');
      expect(reasonScore.body.dataType).toBe('CATEGORICAL');
      expect(reasonScore.body.comment).toBe('negative');
      expect(reasonScore.body.traceId).toBe('run-feedback-1');
    }
    expect(batch[1]!.body.value).toBe('missed_request');
    expect(batch[2]!.body.value).toBe('weak_visual');
  });

  it('does not emit reason scores when no codes were submitted', () => {
    const batch = buildFeedbackPayload(
      makeFeedbackCtx({ reasonCodes: [] }),
    ) as Array<Record<string, any>>;
    expect(batch).toHaveLength(1);
    expect(batch[0]!.body.name).toBe('user_rating');
    expect(batch[0]!.body.value).toBe(1);
  });
});

describe('reportRunFeedback', () => {
  const TEST_CONFIG: LangfuseConfig = {
    baseUrl: 'https://us.cloud.langfuse.com',
    authHeader: 'Basic Zm9vOmJhcg==',
    retries: 0,
    timeoutMs: 1000,
  };

  beforeEach(() => {
    vi.useRealTimers();
  });

  it('skips when metrics consent is off', async () => {
    const fetchSpy = vi.fn();
    await reportRunFeedback(makeFeedbackCtx({ prefs: { metrics: false, content: true } }), {
      config: TEST_CONFIG,
      fetchImpl: fetchSpy as any,
    });
    expect(fetchSpy).not.toHaveBeenCalled();
  });

  it('skips when content consent is off', async () => {
    const fetchSpy = vi.fn();
    await reportRunFeedback(makeFeedbackCtx({ prefs: { metrics: true, content: false } }), {
      config: TEST_CONFIG,
      fetchImpl: fetchSpy as any,
    });
    expect(fetchSpy).not.toHaveBeenCalled();
  });

  it('posts a score-create batch to /api/public/ingestion when consent is on', async () => {
    const fetchSpy = vi.fn().mockResolvedValue(
      new Response(JSON.stringify({ successes: [], errors: [] }), { status: 207 }),
    );
    await reportRunFeedback(
      makeFeedbackCtx({ reasonCodes: ['matched_request'] }),
      { config: TEST_CONFIG, fetchImpl: fetchSpy as any },
    );
    expect(fetchSpy).toHaveBeenCalledTimes(1);
    const [url, init] = fetchSpy.mock.calls[0]!;
    expect(url).toBe('https://us.cloud.langfuse.com/api/public/ingestion');
    expect(init.method).toBe('POST');
    const body = JSON.parse(init.body);
    expect(body.batch).toHaveLength(2);
    expect(body.batch[0].type).toBe('score-create');
    expect(body.batch[0].body.value).toBe(1);
  });
});
