import { EventEmitter } from 'node:events';
import { randomBytes } from 'node:crypto';
import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals';
import { UnauthorizedException } from '@nestjs/common';
import {
  ChannelCredentials,
  Server,
  ServerCredentials,
  status as GrpcStatus,
  type sendUnaryData,
  type ServerUnaryCall,
  type ServerWritableStream,
} from '@grpc/grpc-js';
import { HashUserIdService } from '../../audit/hash-user-id.service';
import { ChatClient } from '../ai-client/chat.client';
import { GrpcMetadataBuilder } from '../ai-client/grpc-metadata.builder';
import { ModelsClient } from '../ai-client/models.client';
import { PrincipalMapper } from '../ai-client/principal.mapper';
import { RagClient } from '../ai-client/rag.client';
import {
  ChatServiceClient,
  ChatServiceService,
  type ChatEvent,
  type ChatRequest,
} from '../gen/apf-ai/chat';
import {
  ModelsServiceClient,
  ModelsServiceService,
  type ListModelsRequest,
  type ListModelsResponse,
} from '../gen/apf-ai/models';
import {
  RagServiceClient,
  RagServiceService,
  type RagSearchRequest,
  type RagSearchResponse,
} from '../gen/apf-ai/rag';
import { Struct } from '../gen/apf-ai/google/protobuf/struct';
import { AiBridgeController } from './ai-bridge.controller';
import type { AiServiceConfig } from '../../config/check-ai-service-config';
import type { AuthenticatedUser } from '../../auth/auth.service';

/**
 * Exercises `AiBridgeController` end-to-end through the gRPC wire
 * against an in-process fake of every service the controller calls.
 *
 * The controller is a thin translation layer (DTO → proto, gRPC →
 * SSE, session → Principal). The spec confirms each direction of
 * the translation lands the right bytes.
 */

const STRONG_SALT = randomBytes(32).toString('base64url');
const ORIGINAL_SALT = process.env['LOG_USER_ID_SALT'];

beforeAll(() => {
  process.env['LOG_USER_ID_SALT'] = STRONG_SALT;
});

afterAll(() => {
  if (ORIGINAL_SALT === undefined) {
    delete process.env['LOG_USER_ID_SALT'];
  } else {
    process.env['LOG_USER_ID_SALT'] = ORIGINAL_SALT;
  }
});

const USER: AuthenticatedUser = {
  oid: 'user-oid-42',
  tid: 'tenant-1',
  username: 'alice@example.org',
  displayName: 'Alice',
  amr: ['pwd', 'mfa'],
  roles: ['admin'],
  groups: [],
};

// ---- Fake gRPC server (Chat + Rag + Models) -------------------

let server: Server;
let port: number;
let chatHandler: (call: ServerWritableStream<ChatRequest, ChatEvent>) => void;
let ragHandler: (
  call: ServerUnaryCall<RagSearchRequest, RagSearchResponse>,
  callback: sendUnaryData<RagSearchResponse>,
) => void;
let modelsHandler: (
  call: ServerUnaryCall<ListModelsRequest, ListModelsResponse>,
  callback: sendUnaryData<ListModelsResponse>,
) => void;
let observedChatRequest: ChatRequest | null = null;

beforeAll(async () => {
  server = new Server();
  server.addService(ChatServiceService, {
    chat: (call: ServerWritableStream<ChatRequest, ChatEvent>) => {
      observedChatRequest = call.request;
      chatHandler(call);
    },
  });
  server.addService(RagServiceService, {
    search: (
      call: ServerUnaryCall<RagSearchRequest, RagSearchResponse>,
      callback: sendUnaryData<RagSearchResponse>,
    ) => ragHandler(call, callback),
  });
  server.addService(ModelsServiceService, {
    listModels: (
      call: ServerUnaryCall<ListModelsRequest, ListModelsResponse>,
      callback: sendUnaryData<ListModelsResponse>,
    ) => modelsHandler(call, callback),
  });

  port = await new Promise<number>((resolve, reject) => {
    server.bindAsync('127.0.0.1:0', ServerCredentials.createInsecure(), (err, bound) => {
      if (err) {
        reject(err);
        return;
      }
      resolve(bound);
    });
  });
});

afterAll(async () => {
  await new Promise<void>((resolve, reject) => {
    server.tryShutdown((err) => {
      if (err) {
        reject(err);
        return;
      }
      resolve();
    });
  });
});

// ---- Controller wiring --------------------------------------

function buildController(): AiBridgeController {
  const config: AiServiceConfig = {
    endpoint: `127.0.0.1:${port}`,
    clientId: 'apf-portal-test',
    useTls: false,
  };
  const credentials = ChannelCredentials.createInsecure();
  const metadata = new GrpcMetadataBuilder(config);
  const chatClient = new ChatClient(new ChatServiceClient(config.endpoint, credentials), metadata);
  const ragClient = new RagClient(new RagServiceClient(config.endpoint, credentials), metadata);
  const modelsClient = new ModelsClient(
    new ModelsServiceClient(config.endpoint, credentials),
    metadata,
  );
  const principalMapper = new PrincipalMapper(new HashUserIdService());
  return new AiBridgeController(chatClient, ragClient, modelsClient, principalMapper);
}

// ---- Mock Express Request / Response -----------------------

interface CapturedResponse {
  statusCode: number | undefined;
  headers: Record<string, string>;
  body: string;
  ended: boolean;
  writableEnded: boolean;
}

function mockReq(user: AuthenticatedUser | undefined): {
  req: import('express').Request;
  close: () => void;
} {
  const emitter = new EventEmitter();
  const req = Object.assign(emitter, {
    session: { user },
  }) as unknown as import('express').Request;
  return {
    req,
    close: () => emitter.emit('close'),
  };
}

function mockRes(): { res: import('express').Response; captured: CapturedResponse } {
  const captured: CapturedResponse = {
    statusCode: undefined,
    headers: {},
    body: '',
    ended: false,
    writableEnded: false,
  };
  const res = {
    set statusCode(value: number) {
      captured.statusCode = value;
    },
    get writableEnded() {
      return captured.writableEnded;
    },
    setHeader(name: string, value: string): void {
      captured.headers[name] = value;
    },
    flushHeaders(): void {
      // no-op for the mock
    },
    write(chunk: string | Buffer): boolean {
      captured.body += typeof chunk === 'string' ? chunk : chunk.toString();
      return true;
    },
    end(chunk?: string | Buffer): void {
      if (chunk !== undefined) {
        captured.body += typeof chunk === 'string' ? chunk : chunk.toString();
      }
      captured.ended = true;
      captured.writableEnded = true;
    },
  };
  return { res: res as unknown as import('express').Response, captured };
}

beforeEach(() => {
  observedChatRequest = null;
});

// ---------------------------------------------------------------

describe('AiBridgeController — POST /api/ai/chat (SSE bridge)', () => {
  it('streams ChatEvent frames as SSE and sets the right headers', async () => {
    chatHandler = (call) => {
      call.write({ token: { token: 'hi', value: 'Hi' } });
      call.write({ token: { token: ' there', value: ' there' } });
      call.write({ done: { stats: { tokensIn: 1, tokensOut: 2, chunksRetrieved: 0 } } });
      call.end();
    };

    const controller = buildController();
    const { req } = mockReq(USER);
    const { res, captured } = mockRes();

    await controller.chat(
      {
        messages: [{ role: 'user', content: 'hello' }],
        conversationId: 'c-1',
      },
      req,
      res,
    );

    expect(captured.headers['Content-Type']).toBe('text/event-stream; charset=utf-8');
    expect(captured.headers['Cache-Control']).toBe('no-cache, no-transform');
    expect(captured.headers['X-Accel-Buffering']).toBe('no');
    expect(captured.body).toContain('event: token\n');
    expect(captured.body).toContain('"value":"Hi"');
    expect(captured.body).toContain('event: done\n');
    expect(captured.ended).toBe(true);
  });

  it('places a hashed subject and pass-through roles in the proto Principal', async () => {
    chatHandler = (call) => {
      call.write({ done: { stats: { tokensIn: 0, tokensOut: 0, chunksRetrieved: 0 } } });
      call.end();
    };
    const controller = buildController();
    const { req } = mockReq(USER);
    const { res } = mockRes();

    await controller.chat(
      {
        messages: [{ role: 'user', content: 'q' }],
      },
      req,
      res,
    );

    expect(observedChatRequest?.principal?.subject).toMatch(/^[0-9a-f]{16}$/);
    expect(observedChatRequest?.principal?.subject).not.toBe(USER.oid);
    expect(observedChatRequest?.principal?.roles).toEqual(['admin']);
    expect(observedChatRequest?.principal?.attributes).toEqual({ tenantId: 'tenant-1' });
    expect(observedChatRequest?.toolsAvailable).toEqual([]);
  });

  it('rejects with 401 when no session.user is present', async () => {
    const controller = buildController();
    const { req } = mockReq(undefined);
    const { res } = mockRes();

    await expect(
      controller.chat({ messages: [{ role: 'user', content: 'q' }] }, req, res),
    ).rejects.toBeInstanceOf(UnauthorizedException);
  });

  it('cancels the gRPC call when the request emits "close" mid-stream', async () => {
    const cancellationObserved = new Promise<void>((resolve) => {
      chatHandler = (call) => {
        call.write({ token: { token: 't', value: 't' } });
        call.on('cancelled', () => resolve());
        // never end — wait for client cancellation
      };
    });

    const controller = buildController();
    const { req, close } = mockReq(USER);
    const { res } = mockRes();

    const completion = controller.chat({ messages: [{ role: 'user', content: 'q' }] }, req, res);

    // Give the server time to emit the first frame then cut the
    // request — this is the controller's documented browser-close
    // pathway.
    setTimeout(close, 30);

    await completion;
    await cancellationObserved;
  });

  it('writes a relay error frame on upstream failure', async () => {
    chatHandler = (call) => {
      call.emit('error', {
        code: GrpcStatus.UNAVAILABLE,
        details: 'upstream down',
        message: 'upstream down',
      });
    };

    const controller = buildController();
    const { req } = mockReq(USER);
    const { res, captured } = mockRes();

    await controller.chat({ messages: [{ role: 'user', content: 'q' }] }, req, res);

    expect(captured.body).toContain('event: error\n');
    expect(captured.body).toContain('"code":"urn:apf-ai:unavailable"');
    expect(captured.body).toContain('"retriable":true');
    expect(captured.ended).toBe(true);
  });
});

describe('AiBridgeController — GET /api/ai/rag/search', () => {
  it('returns the unary response', async () => {
    ragHandler = (_call, callback) => {
      callback(null, {
        chunks: [
          {
            id: 'k',
            documentId: 'd',
            content: 'snippet',
            source: 'src',
            score: 1,
            metadata: Struct.fromPartial({}),
          },
        ],
        correlationId: 'corr-1',
      });
    };

    const controller = buildController();
    const { req } = mockReq(USER);
    const response = await controller.ragSearch({ query: 'hello', topK: 3 }, req);

    expect(response.chunks).toHaveLength(1);
    expect(response.correlationId).toBe('corr-1');
  });

  it('rejects with 401 when no session.user', async () => {
    const controller = buildController();
    const { req } = mockReq(undefined);
    await expect(controller.ragSearch({ query: 'x' }, req)).rejects.toBeInstanceOf(
      UnauthorizedException,
    );
  });
});

describe('AiBridgeController — GET /api/ai/models', () => {
  it('returns the model list', async () => {
    modelsHandler = (_call, callback) => {
      callback(null, {
        active: 'openai-compatible',
        providers: [
          {
            discriminator: 'openai-compatible',
            capabilities: 'chat,embedding',
            endpoint: 'http://ollama:11434/v1',
            model: 'qwen2.5:3b',
            embeddingModel: 'nomic-embed-text',
          },
        ],
      });
    };

    const controller = buildController();
    const { req } = mockReq(USER);
    const response = await controller.listModels(req);
    expect(response.active).toBe('openai-compatible');
    expect(response.providers).toHaveLength(1);
  });

  it('rejects with 401 when no session.user', async () => {
    const controller = buildController();
    const { req } = mockReq(undefined);
    await expect(controller.listModels(req)).rejects.toBeInstanceOf(UnauthorizedException);
  });
});
