import { describe, it, expect, beforeEach, vi, type MockInstance } from 'vitest';
import { ERROR_CODES } from '@insforge/shared-schemas';
import jwt from 'jsonwebtoken';

vi.mock('@/infra/config/app.config.js', () => {
  const c = {
    cloud: { apiHost: 'https://cloud.test', projectId: 'proj-1' },
    app: { jwtSecret: 'secret-1' },
  };
  return {
    config: c,
    appConfig: c,
  };
});

import { CloudComputeProvider } from '@/providers/compute/cloud.provider.js';

type FetchMock = MockInstance<Parameters<typeof fetch>, ReturnType<typeof fetch>>;

describe('CloudComputeProvider', () => {
  let fetchMock: FetchMock;

  beforeEach(() => {
    fetchMock = vi.fn() as unknown as FetchMock;
    global.fetch = fetchMock as unknown as typeof fetch;
  });

  it('createApp POSTs to /apps with sign header containing JWT { sub: project_id }', async () => {
    fetchMock.mockResolvedValue({
      ok: true,
      text: async () => JSON.stringify({ appId: 'ifc-proj-test' }),
    } as Response);

    const provider = CloudComputeProvider.getInstance();
    const result = await provider.createApp({
      name: 'test',
      network: 'test',
      org: 'unused-in-cloud-mode',
    });

    const call = fetchMock.mock.calls[0];
    expect(call[0]).toBe('https://cloud.test/projects/v1/proj-1/compute/apps');
    const headers = (call[1] as RequestInit).headers as Record<string, string>;
    const decoded = jwt.verify(headers.sign, 'secret-1') as { sub: string };
    expect(decoded.sub).toBe('proj-1');
    expect(result.appId).toBe('ifc-proj-test');
  });

  // Regression: live e2e on prod (project 2163e1eb-...) showed Fly 422
  // "Validation failed: Name not a valid network name" because the caller
  // (services.service.ts) used to pass `${projectId}-network` (~44 chars)
  // which exceeded Fly's network-name validator on stricter orgs. The
  // service now uses APP_KEY (~8 chars) — these tests pin the wire format.
  it('createApp forwards network when caller passes a (short) value', async () => {
    fetchMock.mockResolvedValue({
      ok: true,
      text: async () => JSON.stringify({ appId: 'ifc-proj-test' }),
    } as Response);

    const provider = CloudComputeProvider.getInstance();
    await provider.createApp({
      name: 'test',
      network: 'd9byq46t',
      org: 'unused-in-cloud-mode',
    });

    const call = fetchMock.mock.calls[0];
    const sentBody = JSON.parse((call[1] as RequestInit).body as string);
    expect(sentBody).toEqual({ name: 'test', network: 'd9byq46t' });
  });

  it('createApp omits network field when caller does not pass one', async () => {
    fetchMock.mockResolvedValue({
      ok: true,
      text: async () => JSON.stringify({ appId: 'ifc-proj-test' }),
    } as Response);

    const provider = CloudComputeProvider.getInstance();
    await provider.createApp({
      name: 'test',
      org: 'unused-in-cloud-mode',
    });

    const call = fetchMock.mock.calls[0];
    const sentBody = JSON.parse((call[1] as RequestInit).body as string);
    expect(sentBody).toEqual({ name: 'test' });
    expect('network' in sentBody).toBe(false);
  });

  it('throws COMPUTE_CLOUD_UNAVAILABLE on network error', async () => {
    fetchMock.mockRejectedValue(new Error('ECONNREFUSED'));
    const provider = CloudComputeProvider.getInstance();
    await expect(provider.createApp({ name: 't', network: 't', org: 'o' })).rejects.toThrow(
      /COMPUTE_CLOUD_UNAVAILABLE/
    );
  });

  it('throws AppError when cloud returns non-2xx with body', async () => {
    fetchMock.mockResolvedValue({
      ok: false,
      status: 403,
      text: async () =>
        JSON.stringify({
          code: ERROR_CODES.COMPUTE_QUOTA_EXCEEDED,
          error: 'limit reached',
        }),
    } as Response);
    const provider = CloudComputeProvider.getInstance();
    await expect(provider.createApp({ name: 't', network: 't', org: 'o' })).rejects.toThrow(
      new RegExp(`limit reached|${ERROR_CODES.COMPUTE_QUOTA_EXCEEDED}`)
    );
  });

  it('startMachine POSTs to /machines/:id/start with appId in body', async () => {
    fetchMock.mockResolvedValue({ ok: true, text: async () => '' } as Response);
    const provider = CloudComputeProvider.getInstance();
    await provider.startMachine('myapp', 'machine-1');
    const call = fetchMock.mock.calls[0];
    expect(call[0]).toBe('https://cloud.test/projects/v1/proj-1/compute/machines/machine-1/start');
    expect(JSON.parse((call[1] as RequestInit).body as string)).toEqual({ appId: 'myapp' });
  });

  it('listMachines GETs /machines with appId in query', async () => {
    fetchMock.mockResolvedValue({
      ok: true,
      text: async () => JSON.stringify([{ id: 'm1', state: 'started', region: 'iad' }]),
    } as Response);
    const provider = CloudComputeProvider.getInstance();
    const result = await provider.listMachines('myapp');
    const call = fetchMock.mock.calls[0];
    expect(call[0]).toBe('https://cloud.test/projects/v1/proj-1/compute/machines?appId=myapp');
    expect((call[1] as RequestInit).method).toBe('GET');
    expect(result).toEqual([{ id: 'm1', state: 'started', region: 'iad' }]);
  });

  it('getEvents forwards limit in query', async () => {
    fetchMock.mockResolvedValue({
      ok: true,
      text: async () => JSON.stringify([]),
    } as Response);
    const provider = CloudComputeProvider.getInstance();
    await provider.getEvents('myapp', 'machine-1', { limit: 50 });
    const call = fetchMock.mock.calls[0];
    expect(call[0]).toContain('appId=myapp');
    expect(call[0]).toContain('limit=50');
  });

  it('getLogs GETs /machines/:id/logs forwarding appId, limit, next_token and returns the payload', async () => {
    const payload = { lines: [{ timestamp: 1, message: 'hi' }], nextToken: '42' };
    fetchMock.mockResolvedValue({
      ok: true,
      text: async () => JSON.stringify(payload),
    } as Response);
    const provider = CloudComputeProvider.getInstance();

    const result = await provider.getLogs('myapp', 'machine-1', { limit: 200, nextToken: 'cur' });

    const call = fetchMock.mock.calls[0];
    expect(call[0]).toContain('/compute/machines/machine-1/logs');
    expect(call[0]).toContain('appId=myapp');
    expect(call[0]).toContain('limit=200');
    expect(call[0]).toContain('next_token=cur');
    expect((call[1] as RequestInit).method).toBe('GET');
    expect(result).toEqual(payload);
  });

  it('getLogs returns empty result when the cloud responds with no body', async () => {
    fetchMock.mockResolvedValue({ ok: true, text: async () => '' } as Response);
    const provider = CloudComputeProvider.getInstance();

    const result = await provider.getLogs('myapp', 'machine-1');

    expect(result).toEqual({ lines: [], nextToken: null });
  });

  it('throws COMPUTE_CLOUD_UNAVAILABLE on AbortError (timeout)', async () => {
    const abortError = new DOMException('The operation was aborted', 'AbortError');
    fetchMock.mockRejectedValue(abortError);
    const provider = CloudComputeProvider.getInstance();
    await expect(provider.createApp({ name: 't', network: 't', org: 'o' })).rejects.toMatchObject({
      code: 'COMPUTE_CLOUD_UNAVAILABLE',
    });
  });

  it('surfaces COMPUTE_NOT_CONFIGURED when config is missing (not masked as CLOUD_UNAVAILABLE)', async () => {
    const { AppError } = await import('@/utils/errors.js');
    const provider = CloudComputeProvider.getInstance();

    // Force signToken to throw COMPUTE_NOT_CONFIGURED, as it would when isConfigured() is false
    vi.spyOn(provider as unknown as { signToken: () => string }, 'signToken').mockImplementation(
      () => {
        throw new AppError(
          'Cloud compute not configured (need PROJECT_ID, CLOUD_API_HOST, JWT_SECRET)',
          500,
          ERROR_CODES.COMPUTE_NOT_CONFIGURED
        );
      }
    );

    await expect(provider.createApp({ name: 't', network: 't', org: 'o' })).rejects.toThrow(
      /COMPUTE_NOT_CONFIGURED|not configured/
    );
  });
});
