/**
 * Dependency-free HTTP engine for the mcpctl Pulumi provider.
 *
 * Talks to mcpd's REST API directly — the *same* endpoints the `mcpctl` CLI
 * uses (`GET/POST/PUT/DELETE /api/v1/<plural>[/<id>]`) — and mirrors two
 * contracts the CLI guarantees:
 *
 *   1. `apply`'s name-keyed upsert: look the resource up by `name`; if it
 *      exists, PUT with immutable fields stripped; otherwise POST.
 *      (see src/cli/src/commands/apply.ts `applyConfig`)
 *   2. name resolution by listing `/api/v1/<plural>` and matching `name`,
 *      which works uniformly for every resource kind.
 *      (see src/cli/src/commands/shared.ts `resolveNameOrId`)
 *
 * No `mcpctl` binary or `~/.mcpctl` credentials are required — every call
 * carries its own `{ url, token }`. Ported from src/cli/src/api-client.ts.
 */
import http from 'node:http';
import https from 'node:https';

/** Connection + auth for a single mcpd daemon. */
export interface McpdConn {
  url: string;
  token: string;
  timeoutMs?: number;
}

/** A resource row as mcpd returns it (always carries an `id`). */
export interface McpctlEntity {
  id: string;
  name?: string;
  [key: string]: unknown;
}

export class McpdHttpError extends Error {
  readonly status: number;
  readonly body: string;
  constructor(status: number, body: string, method: string, path: string) {
    super(`mcpd ${method} ${path} -> HTTP ${status}: ${body}`);
    this.name = 'McpdHttpError';
    this.status = status;
    this.body = body;
  }
}

interface RawResponse {
  status: number;
  body: string;
}

const MAX_RETRIES = 3;
const DEFAULT_TIMEOUT_MS = 15_000;

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function rawRequest(
  method: string,
  fullUrl: string,
  token: string,
  timeoutMs: number,
  body?: unknown,
): Promise<RawResponse> {
  return new Promise((resolve, reject) => {
    const parsed = new URL(fullUrl);
    const isHttps = parsed.protocol === 'https:';
    const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
    let payload: string | undefined;
    if (body !== undefined) {
      payload = JSON.stringify(body);
      headers['Content-Type'] = 'application/json';
      headers['Content-Length'] = String(Buffer.byteLength(payload));
    }
    const driver = isHttps ? https : http;
    const req = driver.request(
      {
        hostname: parsed.hostname,
        port: parsed.port || (isHttps ? 443 : 80),
        path: parsed.pathname + parsed.search,
        method,
        timeout: timeoutMs,
        headers,
      },
      (res) => {
        const chunks: Buffer[] = [];
        res.on('data', (chunk: Buffer) => chunks.push(chunk));
        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(`Request to ${fullUrl} timed out after ${timeoutMs}ms`));
    });
    if (payload !== undefined) req.write(payload);
    req.end();
  });
}

/**
 * Perform a request, retrying on 429 with exponential backoff (mirrors the
 * CLI's apply backoff). Throws {@link McpdHttpError} on any >=400 status;
 * callers that tolerate 404 catch it.
 */
async function request<T>(conn: McpdConn, method: string, path: string, body?: unknown): Promise<T | null> {
  const timeout = conn.timeoutMs ?? DEFAULT_TIMEOUT_MS;
  const base = conn.url.replace(/\/$/, '');
  let attempt = 0;
  for (;;) {
    const res = await rawRequest(method, `${base}${path}`, conn.token, timeout, body);
    if (res.status === 429 && attempt < MAX_RETRIES) {
      attempt += 1;
      await sleep(250 * 2 ** (attempt - 1));
      continue;
    }
    if (res.status >= 400) {
      throw new McpdHttpError(res.status, res.body, method, path);
    }
    if (!res.body) return null;
    try {
      return JSON.parse(res.body) as T;
    } catch {
      return null;
    }
  }
}

/** List all rows of a resource kind (RBAC-filtered by the token). */
export async function listResource(conn: McpdConn, resource: string): Promise<McpctlEntity[]> {
  const data = await request<McpctlEntity[]>(conn, 'GET', `/api/v1/${resource}`);
  return Array.isArray(data) ? data : [];
}

/** Resolve a resource by human name (list + match), like the CLI does. */
export async function findByName(conn: McpdConn, resource: string, name: string): Promise<McpctlEntity | null> {
  const items = await listResource(conn, resource);
  return items.find((item) => item.name === name) ?? null;
}

/**
 * Name-keyed upsert. `doc` is the full body (must include `name`). If a row
 * with that name exists, PUT it with `immutable` fields stripped; otherwise
 * POST `doc`. Mirrors apply.ts exactly.
 */
export async function upsert(
  conn: McpdConn,
  resource: string,
  doc: Record<string, unknown>,
  immutable: string[],
): Promise<McpctlEntity> {
  const name = String(doc['name'] ?? '');
  const existing = await findByName(conn, resource, name);
  if (existing) {
    const updateBody: Record<string, unknown> = {};
    for (const [key, value] of Object.entries(doc)) {
      if (!immutable.includes(key)) updateBody[key] = value;
    }
    const updated = await request<McpctlEntity>(conn, 'PUT', `/api/v1/${resource}/${existing.id}`, updateBody);
    return updated ?? { ...existing, ...updateBody };
  }
  const created = await request<McpctlEntity>(conn, 'POST', `/api/v1/${resource}`, doc);
  if (!created || typeof created.id !== 'string') {
    throw new Error(`create ${resource}/${name}: mcpd returned no id`);
  }
  return created;
}

/** Delete a resource by name. No-op if it is already gone (idempotent). */
export async function del(conn: McpdConn, resource: string, name: string): Promise<void> {
  const existing = await findByName(conn, resource, name);
  if (!existing) return;
  try {
    await request(conn, 'DELETE', `/api/v1/${resource}/${existing.id}`);
  } catch (err) {
    if (err instanceof McpdHttpError && err.status === 404) return;
    throw err;
  }
}
