/**
 * Phase 3b — verify stream-proxy emits Prometheus counters on response
 * completion. We don't mock prom-client; instead we register a fresh
 * Registry per test and grep the `metrics()` output.
 */
import { describe, it, expect, beforeEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { Registry } from 'prom-client';
import { createGatewayMetrics, type GatewayMetrics } from '../metrics/gateway-metrics.js';
import {
  buildChatCompletionsHandler,
  emitRequestMetric,
  classifyResponseStatus,
} from './stream-proxy.js';
import type { Router } from './router.js';

function makeRouter(model: string): Router {
  return {
    pick: (req: string) => (req === model ? { id: 'gpu-a', endpoint: 'http://upstream', model, apiKey: undefined } : null),
  } as unknown as Router;
}

function makeFetch(body: string, status = 200): typeof fetch {
  return (async (_url, _init) => {
    return new Response(body, {
      status,
      headers: { 'content-type': 'application/json' },
    });
  }) as unknown as typeof fetch;
}

describe('stream-proxy metrics emission', () => {
  let reg: Registry;
  let metrics: GatewayMetrics;

  beforeEach(() => {
    reg = new Registry();
    metrics = createGatewayMetrics(reg, 'aao_gateway_sp');
  });

  it('non-streaming success increments requests_total + duration + tokens', async () => {
    const app = express();
    app.use(express.json());
    app.use((req, _res, next) => {
      (req as unknown as { gatewayAuth: object }).gatewayAuth = { team: 'alpha', source: 'config' };
      next();
    });
    const handler = buildChatCompletionsHandler({
      router: makeRouter('qwen3:8b'),
      upstreamTimeoutSec: 5,
      requestTimeoutSec: 10,
      fetchImpl: makeFetch(
        JSON.stringify({ choices: [{ message: { content: 'ok' } }], usage: { prompt_tokens: 12, completion_tokens: 5 } }),
        200,
      ),
      metrics,
    });
    app.post('/v1/chat/completions', handler);
    const res = await request(app)
      .post('/v1/chat/completions')
      .send({ model: 'qwen3:8b', stream: false });
    expect(res.status).toBe(200);
    const dump = await reg.metrics();
    expect(dump).toMatch(/aao_gateway_sp_requests_total\{[^}]*team="alpha"[^}]*backend="gpu-a"[^}]*status="success"[^}]*\} 1/);
    expect(dump).toMatch(/aao_gateway_sp_request_duration_seconds_count\{[^}]*team="alpha"[^}]*\} 1/);
    expect(dump).toMatch(/aao_gateway_sp_tokens_total\{[^}]*direction="in"[^}]*\} 12/);
    expect(dump).toMatch(/aao_gateway_sp_tokens_total\{[^}]*direction="out"[^}]*\} 5/);
  });

  it('no-backend rejection emits status=no_backend', async () => {
    const app = express();
    app.use(express.json());
    app.use((req, _res, next) => {
      (req as unknown as { gatewayAuth: object }).gatewayAuth = { team: 'bravo', source: 'config' };
      next();
    });
    const handler = buildChatCompletionsHandler({
      router: { pick: () => null } as unknown as Router,
      upstreamTimeoutSec: 5,
      requestTimeoutSec: 10,
      fetchImpl: makeFetch('{}', 200),
      metrics,
    });
    app.post('/v1/chat/completions', handler);
    const res = await request(app).post('/v1/chat/completions').send({ model: 'unknown' });
    expect(res.status).toBe(503);
    const dump = await reg.metrics();
    expect(dump).toMatch(/aao_gateway_sp_requests_total\{[^}]*team="bravo"[^}]*status="no_backend"[^}]*\} 1/);
  });

  it('upstream fetch failure emits status=upstream_error', async () => {
    const app = express();
    app.use(express.json());
    app.use((req, _res, next) => {
      (req as unknown as { gatewayAuth: object }).gatewayAuth = { team: 'gamma', source: 'config' };
      next();
    });
    const handler = buildChatCompletionsHandler({
      router: makeRouter('qwen3:8b'),
      upstreamTimeoutSec: 5,
      requestTimeoutSec: 10,
      fetchImpl: (() => Promise.reject(new Error('econnrefused'))) as unknown as typeof fetch,
      metrics,
    });
    app.post('/v1/chat/completions', handler);
    const res = await request(app)
      .post('/v1/chat/completions')
      .send({ model: 'qwen3:8b', stream: false });
    expect(res.status).toBe(502);
    const dump = await reg.metrics();
    expect(dump).toMatch(/aao_gateway_sp_requests_total\{[^}]*team="gamma"[^}]*status="upstream_error"[^}]*\} 1/);
  });

  it('missing model field is recorded as upstream_error', async () => {
    const app = express();
    app.use(express.json());
    app.use((req, _res, next) => {
      (req as unknown as { gatewayAuth: object }).gatewayAuth = { team: 'd', source: 'config' };
      next();
    });
    const handler = buildChatCompletionsHandler({
      router: makeRouter('qwen3:8b'),
      upstreamTimeoutSec: 5,
      requestTimeoutSec: 10,
      fetchImpl: makeFetch('{}', 200),
      metrics,
    });
    app.post('/v1/chat/completions', handler);
    const res = await request(app).post('/v1/chat/completions').send({});
    expect(res.status).toBe(400);
    const dump = await reg.metrics();
    expect(dump).toMatch(/aao_gateway_sp_requests_total\{[^}]*team="d"[^}]*status="upstream_error"[^}]*\} 1/);
  });

  it('emitRequestMetric handles undefined metrics handle (no-op)', () => {
    expect(() => emitRequestMetric(undefined, {
      team: 'x', backend: 'y', model: 'z', status: 'success', durationMs: 10,
    })).not.toThrow();
  });

  it('emitRequestMetric skips zero-token emissions', async () => {
    emitRequestMetric(metrics, {
      team: 't', backend: 'b', model: 'm', status: 'success', durationMs: 100, tokensIn: 0, tokensOut: 0,
    });
    const dump = await reg.metrics();
    // requests_total should be 1 but tokens_total should NOT appear with zero
    expect(dump).toMatch(/aao_gateway_sp_requests_total\{[^}]*team="t"[^}]*\} 1/);
    expect(dump).not.toMatch(/aao_gateway_sp_tokens_total\{[^}]*team="t"/);
  });

  it('classifyResponseStatus maps abort + shutdown registry to gateway_shutdown', () => {
    const ctrl = new AbortController();
    ctrl.abort();
    const status = classifyResponseStatus(200, ctrl, {
      register: () => () => {},
      signalShutdown: () => {},
      isShuttingDown: () => true,
      reset: () => {},
      size: () => 0,
    });
    expect(status).toBe('gateway_shutdown');
  });

  it('classifyResponseStatus maps abort without registry to gateway_timeout', () => {
    const ctrl = new AbortController();
    ctrl.abort();
    expect(classifyResponseStatus(200, ctrl, undefined)).toBe('gateway_timeout');
  });

  it('classifyResponseStatus maps non-2xx to upstream_error', () => {
    const ctrl = new AbortController();
    expect(classifyResponseStatus(503, ctrl, undefined)).toBe('upstream_error');
  });

  it('classifyResponseStatus maps 2xx without abort to success', () => {
    const ctrl = new AbortController();
    expect(classifyResponseStatus(200, ctrl, undefined)).toBe('success');
  });

  // Phase 3b post-review — streaming success must not be misclassified as
  // gateway_timeout when a late `req close` event aborts the controller
  // AFTER the for-await loop completed successfully.
  describe('streaming clean-close → success (Phase 3b post-review)', () => {
    it('object form: streamCompletedOk + late abort → success', () => {
      const ctrl = new AbortController();
      ctrl.abort();
      const status = classifyResponseStatus({
        upstreamStatus: 200,
        ctrl,
        bodyReadOk: true,
        resWritableEnded: true,
      });
      expect(status).toBe('success');
    });

    it('object form: resWritableEnded alone is also a success signal', () => {
      // streamCompletedOk false but res ended → still success. This is
      // the supertest race where Express fires close after the body has
      // already been fully sent.
      const ctrl = new AbortController();
      ctrl.abort();
      const status = classifyResponseStatus({
        upstreamStatus: 200,
        ctrl,
        bodyReadOk: false,
        resWritableEnded: true,
      });
      expect(status).toBe('success');
    });

    it('object form: aborted before stream completed + res not ended → gateway_timeout', () => {
      const ctrl = new AbortController();
      ctrl.abort();
      const status = classifyResponseStatus({
        upstreamStatus: 200,
        ctrl,
        bodyReadOk: false,
        resWritableEnded: false,
      });
      expect(status).toBe('gateway_timeout');
    });

    it('object form: shutdown registry + abort + stream incomplete → gateway_shutdown', () => {
      const ctrl = new AbortController();
      ctrl.abort();
      const status = classifyResponseStatus({
        upstreamStatus: 200,
        ctrl,
        streamRegistry: {
          register: () => () => {},
          signalShutdown: () => {},
          isShuttingDown: () => true,
          reset: () => {},
          size: () => 0,
        },
        bodyReadOk: false,
        resWritableEnded: false,
      });
      expect(status).toBe('gateway_shutdown');
    });

    it('object form: non-2xx + bodyReadOk → upstream_error (success only on 2xx)', () => {
      const ctrl = new AbortController();
      const status = classifyResponseStatus({
        upstreamStatus: 503,
        ctrl,
        bodyReadOk: true,
        resWritableEnded: true,
      });
      expect(status).toBe('upstream_error');
    });

    it('backwards-compat positional signature still works', () => {
      const ctrl = new AbortController();
      ctrl.abort();
      // Old call site that pre-dates the resWritableEnded signal.
      expect(classifyResponseStatus(200, ctrl, undefined, true)).toBe('success');
      expect(classifyResponseStatus(200, ctrl, undefined, false)).toBe('gateway_timeout');
    });
  });
});
