import jwt from 'jsonwebtoken';
import { appConfig } from '@/infra/config/app.config.js';
import { AppError } from '@/utils/errors.js';
import { ERROR_CODES } from '@insforge/shared-schemas';
import type {
  ComputeProvider,
  LaunchMachineParams,
  UpdateMachineParams,
  MachineSummary,
  ComputeEvent,
  ComputeLogsResult,
} from './compute.provider.js';

export class CloudComputeProvider implements ComputeProvider {
  private static instance: CloudComputeProvider;

  static getInstance(): CloudComputeProvider {
    if (!CloudComputeProvider.instance) {
      CloudComputeProvider.instance = new CloudComputeProvider();
    }
    return CloudComputeProvider.instance;
  }

  isConfigured(): boolean {
    return (
      !!appConfig.cloud?.projectId &&
      appConfig.cloud.projectId !== 'local' &&
      !!appConfig.cloud?.apiHost &&
      !!appConfig.app?.jwtSecret
    );
  }

  private signToken(): string {
    if (!this.isConfigured()) {
      throw new AppError(
        'Cloud compute not configured (need PROJECT_ID, CLOUD_API_HOST, JWT_SECRET)',
        500,
        ERROR_CODES.COMPUTE_NOT_CONFIGURED
      );
    }
    return jwt.sign({ sub: appConfig.cloud.projectId }, appConfig.app.jwtSecret, {
      expiresIn: '10m',
    });
  }

  private url(path: string): string {
    return `${appConfig.cloud.apiHost}/projects/v1/${appConfig.cloud.projectId}/compute${path}`;
  }

  private async call<T>(method: string, path: string, body?: unknown): Promise<T | undefined> {
    // signToken throws AppError(COMPUTE_NOT_CONFIGURED) if config missing —
    // we want that to surface to the caller, not get masked as CLOUD_UNAVAILABLE.
    const sign = this.signToken();

    let response: Response;
    try {
      response = await fetch(this.url(path), {
        method,
        headers: {
          sign,
          'Content-Type': 'application/json',
        },
        body: body !== undefined ? JSON.stringify(body) : undefined,
        // 60s: launchMachine can legitimately take 15-25s (Fly app create +
        // IP allocation retry loop + machine provisioning). 15s was too tight
        // and produced false-positive COMPUTE_CLOUD_UNAVAILABLE errors that
        // caused orphaned Fly resources.
        signal: AbortSignal.timeout(60_000),
      });
    } catch (err) {
      // Only network/fetch errors arrive here — re-wrap as COMPUTE_CLOUD_UNAVAILABLE
      throw new AppError(
        `COMPUTE_CLOUD_UNAVAILABLE: ${(err as Error).message}`,
        503,
        ERROR_CODES.COMPUTE_CLOUD_UNAVAILABLE,
        'Check CLOUD_API_HOST is reachable and verify cloud backend health.'
      );
    }
    const text = await response.text();
    if (!response.ok) {
      throw new AppError(
        text || `Cloud compute error (${response.status})`,
        response.status,
        ERROR_CODES.COMPUTE_PROVIDER_ERROR
      );
    }
    return text ? (JSON.parse(text) as T) : undefined;
  }

  async createApp(params: { name: string; network?: string; org: string }) {
    // Forward `network` only if the caller passed one. We keep it short
    // (services.service.ts uses APP_KEY, ~8 chars) so Fly's network-name
    // validator accepts it. Live e2e on prod (project 2163e1eb-…) showed
    // the previous long `${projectId}-network` (~44 chars) 422'd as
    // "Name not a valid network name".
    const body: Record<string, unknown> = { name: params.name };
    if (params.network !== undefined) {
      body.network = params.network;
    }
    const result = await this.call<{ appId: string; serviceId?: string }>('POST', '/apps', body);
    return { appId: result?.appId ?? params.name };
  }

  async destroyApp(appId: string): Promise<void> {
    await this.call('DELETE', `/apps/${encodeURIComponent(appId)}`);
  }

  // Fetch a Fly deploy token for one app from the cloud. The cloud mints
  // the token using its own org-scoped FLY_API_TOKEN, so the OSS instance
  // (and ultimately the CLI) never needs its own Fly credentials. Token is
  // 20-min lifetime, scoped to one app — see cloud's
  // POST /projects/v1/{projectId}/compute/apps/{appId}/deploy-token.
  async issueDeployToken(appId: string): Promise<{ token: string; expirySeconds: number }> {
    const result = await this.call<{ token: string; expirySeconds: number }>(
      'POST',
      `/apps/${encodeURIComponent(appId)}/deploy-token`
    );
    if (!result) {
      throw new AppError(
        'Cloud returned empty deploy-token response',
        500,
        ERROR_CODES.COMPUTE_PROVIDER_ERROR
      );
    }
    return result;
  }

  async launchMachine(params: LaunchMachineParams): Promise<{ machineId: string }> {
    const result = await this.call<{ machineId: string }>('POST', '/machines', params);
    if (!result?.machineId) {
      throw new AppError(
        `Cloud compute returned empty machine payload for app ${params.appId}`,
        502,
        ERROR_CODES.COMPUTE_PROVIDER_ERROR
      );
    }
    return { machineId: result.machineId };
  }

  async updateMachine(params: UpdateMachineParams): Promise<void> {
    await this.call('PATCH', `/machines/${encodeURIComponent(params.machineId)}`, params);
  }

  async stopMachine(appId: string, machineId: string): Promise<void> {
    await this.call('POST', `/machines/${encodeURIComponent(machineId)}/stop`, { appId });
  }

  async startMachine(appId: string, machineId: string): Promise<void> {
    await this.call('POST', `/machines/${encodeURIComponent(machineId)}/start`, { appId });
  }

  async destroyMachine(appId: string, machineId: string): Promise<void> {
    await this.call('DELETE', `/machines/${encodeURIComponent(machineId)}`, { appId });
  }

  async listMachines(appId: string): Promise<MachineSummary[]> {
    return (
      (await this.call<MachineSummary[]>('GET', `/machines?appId=${encodeURIComponent(appId)}`)) ??
      []
    );
  }

  async getMachineStatus(appId: string, machineId: string): Promise<{ state: string }> {
    const result = await this.call<{ state: string }>(
      'GET',
      `/machines/${encodeURIComponent(machineId)}?appId=${encodeURIComponent(appId)}`
    );
    if (!result?.state) {
      throw new AppError(
        `Cloud compute returned empty status payload for ${appId}/${machineId}`,
        502,
        ERROR_CODES.COMPUTE_PROVIDER_ERROR
      );
    }
    return result;
  }

  async getEvents(
    appId: string,
    machineId: string,
    options?: { limit?: number }
  ): Promise<ComputeEvent[]> {
    const qs =
      `?appId=${encodeURIComponent(appId)}` + (options?.limit ? `&limit=${options.limit}` : '');
    return (
      (await this.call<ComputeEvent[]>(
        'GET',
        `/machines/${encodeURIComponent(machineId)}/events${qs}`
      )) ?? []
    );
  }

  /**
   * Fetch container logs by delegating to the cloud control plane's
   * `GET /machines/:id/logs`, which holds the Fly org token and proxies to Fly.
   * Returns empty results if the control plane responds with no body.
   */
  async getLogs(
    appId: string,
    machineId: string,
    options?: { limit?: number; nextToken?: string }
  ): Promise<ComputeLogsResult> {
    const params = new URLSearchParams({ appId });
    if (options?.limit) {
      params.set('limit', String(options.limit));
    }
    if (options?.nextToken) {
      params.set('next_token', options.nextToken);
    }
    const result = await this.call<ComputeLogsResult>(
      'GET',
      `/machines/${encodeURIComponent(machineId)}/logs?${params.toString()}`
    );
    return result ?? { lines: [], nextToken: null };
  }

  async waitForState(
    appId: string,
    machineId: string,
    targetStates: string[],
    timeoutMs = 60_000
  ): Promise<string> {
    const start = Date.now();
    while (Date.now() - start < timeoutMs) {
      try {
        const { state } = await this.getMachineStatus(appId, machineId);
        if (targetStates.includes(state)) {
          return state;
        }
      } catch {
        // Transient network/cloud blip — keep polling until the deadline
        // rather than aborting the wait. Mirrors FlyProvider.waitForState.
      }
      await new Promise((r) => setTimeout(r, 1500));
    }
    throw new AppError(
      `Machine ${machineId} did not reach ${targetStates.join('|')} within ${timeoutMs}ms`,
      504,
      ERROR_CODES.COMPUTE_PROVIDER_ERROR
    );
  }
}
