/**
 * Smoke tests: virtual-LLM register → infer relay → cleanup against a live
 * mcpd. Uses an in-process LlmProvider (returns canned content) so we
 * exercise the SSE control plane and the kind=virtual infer branch
 * without depending on a real upstream model.
 *
 * The 90-s heartbeat-stale flip and 4-h auto-deletion are covered by unit
 * tests (mcpd virtual-llm-service.test.ts); waiting > 90 s in smoke would
 * triple the suite duration.
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import http from 'node:http';
import https from 'node:https';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
  VirtualLlmRegistrar,
  type RegistrarPublishedProvider,
} from '../../src/providers/registrar.js';
import type { LlmProvider, CompletionResult } from '../../src/providers/types.js';

const MCPD_URL = process.env.MCPD_URL ?? 'https://mcpctl.ad.itaz.eu';
const SUFFIX = Date.now().toString(36);
const PROVIDER_NAME = `smoke-virtual-${SUFFIX}`;

function makeFakeProvider(name: string, content: string): LlmProvider {
  return {
    name,
    async complete(): Promise<CompletionResult> {
      return {
        content,
        toolCalls: [],
        usage: { promptTokens: 1, completionTokens: 4, totalTokens: 5 },
        finishReason: 'stop',
      };
    },
    async listModels() { return []; },
    async isAvailable() { return true; },
  };
}

function healthz(url: string, timeoutMs = 5000): Promise<boolean> {
  return new Promise((resolve) => {
    const parsed = new URL(`${url.replace(/\/$/, '')}/healthz`);
    const driver = parsed.protocol === 'https:' ? https : http;
    const req = driver.get(
      {
        hostname: parsed.hostname,
        port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
        path: parsed.pathname,
        timeout: timeoutMs,
      },
      (res) => { resolve((res.statusCode ?? 500) < 500); res.resume(); },
    );
    req.on('error', () => resolve(false));
    req.on('timeout', () => { req.destroy(); resolve(false); });
  });
}

function readToken(): string | null {
  try {
    const home = process.env.HOME ?? '';
    const path = `${home}/.mcpctl/credentials`;
    // eslint-disable-next-line @typescript-eslint/no-require-imports
    const fs = require('node:fs') as typeof import('node:fs');
    if (!fs.existsSync(path)) return null;
    const raw = fs.readFileSync(path, 'utf-8');
    const parsed = JSON.parse(raw) as { token?: string };
    return parsed.token ?? null;
  } catch {
    return null;
  }
}

interface HttpResponse { status: number; body: string }

function httpRequest(method: string, urlStr: string, body: unknown): Promise<HttpResponse> {
  return new Promise((resolve, reject) => {
    const tokenRaw = readToken();
    const parsed = new URL(urlStr);
    const driver = parsed.protocol === 'https:' ? https : http;
    const headers: Record<string, string> = {
      Accept: 'application/json',
      ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
      ...(tokenRaw !== null ? { Authorization: `Bearer ${tokenRaw}` } : {}),
    };
    const req = driver.request({
      hostname: parsed.hostname,
      port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
      path: parsed.pathname + parsed.search,
      method,
      headers,
      timeout: 30_000,
    }, (res) => {
      const chunks: Buffer[] = [];
      res.on('data', (c: Buffer) => chunks.push(c));
      res.on('end', () => {
        resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') });
      });
    });
    req.on('error', reject);
    req.on('timeout', () => { req.destroy(); reject(new Error(`httpRequest timeout: ${method} ${urlStr}`)); });
    if (body !== undefined) req.write(JSON.stringify(body));
    req.end();
  });
}

let mcpdUp = false;
let registrar: VirtualLlmRegistrar | null = null;
let tempDir: string;

describe('virtual-LLM smoke', () => {
  beforeAll(async () => {
    mcpdUp = await healthz(MCPD_URL);
    if (!mcpdUp) {
      // eslint-disable-next-line no-console
      console.warn(`\n  ○ virtual-llm smoke: skipped — ${MCPD_URL}/healthz unreachable.\n`);
      return;
    }
    if (readToken() === null) {
      mcpdUp = false;
      // eslint-disable-next-line no-console
      console.warn('\n  ○ virtual-llm smoke: skipped — no ~/.mcpctl/credentials.\n');
      return;
    }
    tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-virtual-llm-smoke-'));
  }, 20_000);

  afterAll(async () => {
    if (registrar !== null) registrar.stop();
    if (tempDir !== undefined) rmSync(tempDir, { recursive: true, force: true });
    // Best-effort cleanup of the row in case the disconnect didn't finish
    // before mcpd's heartbeat watchdog ticks. Idempotent.
    if (mcpdUp) {
      const list = await httpRequest('GET', `${MCPD_URL}/api/v1/llms`, undefined);
      if (list.status === 200) {
        const rows = JSON.parse(list.body) as Array<{ id: string; name: string }>;
        const row = rows.find((r) => r.name === PROVIDER_NAME);
        if (row !== undefined) {
          await httpRequest('DELETE', `${MCPD_URL}/api/v1/llms/${row.id}`, undefined);
        }
      }
    }
  });

  it('registrar publishes the provider and mcpd lists it as kind=virtual / status=active', async () => {
    if (!mcpdUp) return;
    const token = readToken();
    if (token === null) return;
    const published: RegistrarPublishedProvider[] = [
      { provider: makeFakeProvider(PROVIDER_NAME, 'hi from smoke'), type: 'openai', model: 'fake-smoke', tier: 'fast' },
    ];
    registrar = new VirtualLlmRegistrar({
      mcpdUrl: MCPD_URL,
      token,
      publishedProviders: published,
      sessionFilePath: join(tempDir, 'session'),
      log: { info: () => {}, warn: () => {}, error: () => {} },
      heartbeatIntervalMs: 60_000,
    });
    await registrar.start();
    expect(registrar.getSessionId()).not.toBeNull();
    // Give the SSE handshake + register a moment to settle on mcpd's side.
    await new Promise((r) => setTimeout(r, 400));

    const res = await httpRequest('GET', `${MCPD_URL}/api/v1/llms`, undefined);
    expect(res.status).toBe(200);
    const rows = JSON.parse(res.body) as Array<{ name: string; kind: string; status: string; type: string; model: string }>;
    const row = rows.find((r) => r.name === PROVIDER_NAME);
    expect(row, `${PROVIDER_NAME} must be present`).toBeDefined();
    expect(row!.kind).toBe('virtual');
    expect(row!.status).toBe('active');
    expect(row!.type).toBe('openai');
    expect(row!.model).toBe('fake-smoke');
  }, 30_000);

  it('mcpd routes /api/v1/llms/<virtual>/infer back through the SSE relay to the fake provider', async () => {
    if (!mcpdUp) return;
    const res = await httpRequest('POST', `${MCPD_URL}/api/v1/llms/${PROVIDER_NAME}/infer`, {
      messages: [{ role: 'user', content: 'say something' }],
    });
    expect(res.status).toBe(200);
    const body = JSON.parse(res.body) as {
      choices?: Array<{ message?: { content?: string }; finish_reason?: string }>;
      usage?: { total_tokens?: number };
    };
    expect(body.choices?.[0]?.message?.content).toBe('hi from smoke');
    expect(body.choices?.[0]?.finish_reason).toBe('stop');
    expect(body.usage?.total_tokens).toBe(5);
  }, 30_000);

  it('returns 503 with a clear error when the publisher disconnects mid-session', async () => {
    if (!mcpdUp) return;
    if (registrar !== null) {
      registrar.stop();
      registrar = null;
    }
    // Immediately after stop(), the SSE socket closes and mcpd's
    // unbindSession flips the row to inactive. Inference should 503.
    await new Promise((r) => setTimeout(r, 300));

    const res = await httpRequest('POST', `${MCPD_URL}/api/v1/llms/${PROVIDER_NAME}/infer`, {
      messages: [{ role: 'user', content: 'still there?' }],
    });
    expect(res.status).toBe(503);
    expect(res.body).toMatch(/publisher offline|inactive/);
  }, 30_000);
});

// ── v2: hibernating + wake-on-demand ──

const HIBERNATING_NAME = `smoke-virtual-hib-${SUFFIX}`;
let hibernatingRegistrar: VirtualLlmRegistrar | null = null;

/**
 * Provider that's "asleep" until \`wakeFn()\` is called. Used to drive
 * the wake-on-demand smoke without standing up an actual sleep/wake
 * controller — we flip the bool from inside the wake recipe.
 */
function makeSleepingProvider(name: string, content: string): {
  provider: LlmProvider;
  wakeFn: () => void;
  wakeCount: () => number;
} {
  let awake = false;
  let count = 0;
  const provider: LlmProvider = {
    name,
    async complete(): Promise<CompletionResult> {
      if (!awake) throw new Error('provider not awake');
      return {
        content,
        toolCalls: [],
        usage: { promptTokens: 1, completionTokens: 4, totalTokens: 5 },
        finishReason: 'stop',
      };
    },
    async listModels() { return []; },
    async isAvailable() { return awake; },
  };
  return {
    provider,
    wakeFn: () => { awake = true; count += 1; },
    wakeCount: () => count,
  };
}

describe('virtual-LLM smoke — wake-on-demand', () => {
  let wakeServerUrl: string;
  let wakeServer: http.Server;
  let wakeFn: (() => void) | null = null;

  beforeAll(async () => {
    if (!mcpdUp) return;
    // Tiny in-process "wake controller" — receives the http wake recipe
    // POST and flips the local provider's `awake` bool.
    await new Promise<void>((resolve) => {
      wakeServer = http.createServer((req, res) => {
        if (req.url === '/wake' && wakeFn !== null) {
          wakeFn();
          res.writeHead(200);
          res.end('woken');
          return;
        }
        res.writeHead(404);
        res.end();
      });
      wakeServer.listen(0, '127.0.0.1', () => {
        const addr = wakeServer.address();
        if (addr === null || typeof addr === 'string') throw new Error('listen failed');
        wakeServerUrl = `http://127.0.0.1:${String(addr.port)}/wake`;
        resolve();
      });
    });
  });

  afterAll(async () => {
    if (hibernatingRegistrar !== null) hibernatingRegistrar.stop();
    if (wakeServer) await new Promise<void>((r) => wakeServer.close(() => r()));
    if (mcpdUp) {
      const list = await httpRequest('GET', `${MCPD_URL}/api/v1/llms`, undefined);
      if (list.status === 200) {
        const rows = JSON.parse(list.body) as Array<{ id: string; name: string }>;
        const row = rows.find((r) => r.name === HIBERNATING_NAME);
        if (row !== undefined) {
          await httpRequest('DELETE', `${MCPD_URL}/api/v1/llms/${row.id}`, undefined);
        }
      }
    }
  });

  it('publishes a sleeping provider as kind=virtual / status=hibernating', async () => {
    if (!mcpdUp) return;
    const token = readToken();
    if (token === null) return;
    const sleeping = makeSleepingProvider(HIBERNATING_NAME, 'awake now');
    wakeFn = sleeping.wakeFn;

    const published: RegistrarPublishedProvider[] = [{
      provider: sleeping.provider,
      type: 'openai',
      model: 'fake-hibernating',
      tier: 'fast',
      wake: { type: 'http', url: wakeServerUrl, method: 'POST', maxWaitSeconds: 5 },
    }];
    hibernatingRegistrar = new VirtualLlmRegistrar({
      mcpdUrl: MCPD_URL,
      token,
      publishedProviders: published,
      sessionFilePath: join(tempDir, 'hib-session'),
      log: { info: () => {}, warn: () => {}, error: () => {} },
      heartbeatIntervalMs: 60_000,
    });
    await hibernatingRegistrar.start();
    await new Promise((r) => setTimeout(r, 400));

    const res = await httpRequest('GET', `${MCPD_URL}/api/v1/llms`, undefined);
    expect(res.status).toBe(200);
    const rows = JSON.parse(res.body) as Array<{ name: string; kind: string; status: string }>;
    const row = rows.find((r) => r.name === HIBERNATING_NAME);
    expect(row, `${HIBERNATING_NAME} must be present`).toBeDefined();
    expect(row!.kind).toBe('virtual');
    expect(row!.status).toBe('hibernating');
  }, 30_000);

  it('first inference triggers the wake recipe and then completes', async () => {
    if (!mcpdUp) return;
    // wakeFn was set in the previous test; it flips the provider's
    // `awake` bool when the wake POST lands.
    const res = await httpRequest('POST', `${MCPD_URL}/api/v1/llms/${HIBERNATING_NAME}/infer`, {
      messages: [{ role: 'user', content: 'wake then say hello' }],
    });
    expect(res.status).toBe(200);
    const body = JSON.parse(res.body) as { choices?: Array<{ message?: { content?: string } }> };
    expect(body.choices?.[0]?.message?.content).toBe('awake now');

    // After the wake, the row should now be active.
    const list = await httpRequest('GET', `${MCPD_URL}/api/v1/llms`, undefined);
    const rows = JSON.parse(list.body) as Array<{ name: string; status: string }>;
    expect(rows.find((r) => r.name === HIBERNATING_NAME)?.status).toBe('active');
  }, 30_000);
});
