import { BaseService, type BaseServiceContext } from '@learnitive/base-utils';
import { SERVICE_NAME } from '@learnitive/constants';

const DEFAULT_MAX_RESPONSE_BYTES = 64 * 1024 * 1024;
const DEFAULT_TIMEOUT_MS = 60_000;
const MAX_REQUEST_BODY_BYTES = 64 * 1024 * 1024;

export type ProjectHttpMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';

export type ProjectHttpRequest = {
  url: string;
  method?: ProjectHttpMethod;
  headers?: Readonly<Record<string, string>>;
  body?: Uint8Array | string;
  timeoutMs?: number;
  maxResponseBytes?: number;
};

export type ProjectHttpResponse = {
  url: string;
  status: number;
  statusText: string;
  headers: Readonly<Record<string, string>>;
  bytes: Uint8Array;
  redirected: boolean;
  contentType?: string;
};

function safeHttpUrl(value: string): URL {
  let url: URL;
  try {
    url = new URL(value);
  } catch {
    throw new Error(`Invalid HTTP URL: ${value}`);
  }
  if (url.protocol !== 'http:' && url.protocol !== 'https:') {
    throw new Error('Browser HTTP is restricted to http:// and https:// URLs.');
  }
  if (url.username || url.password) {
    throw new Error('Credentials embedded in URLs are not allowed. Use an explicit request header after reviewing the network action.');
  }
  return url;
}

function normalizedHeaders(input: Readonly<Record<string, string>> | undefined): Headers {
  const headers = new Headers();
  for (const [name, value] of Object.entries(input ?? {})) {
    const normalizedName = name.trim();
    if (!normalizedName) continue;
    if (/^(?:cookie|host|origin|referer|content-length)$/iu.test(normalizedName)) {
      throw new Error(`Browser HTTP does not allow setting the ${normalizedName} header.`);
    }
    headers.set(normalizedName, value);
  }
  return headers;
}

async function readBoundedBody(response: Response, maxBytes: number, signal: AbortSignal): Promise<Uint8Array> {
  if (response.body === null) return new Uint8Array();
  const advertised = Number(response.headers.get('content-length'));
  if (Number.isFinite(advertised) && advertised > maxBytes) {
    throw new Error(`HTTP response is ${advertised} bytes, above the ${maxBytes} byte Learnitive limit.`);
  }

  const reader = response.body.getReader();
  const chunks: Uint8Array[] = [];
  let total = 0;
  try {
    while (true) {
      signal.throwIfAborted();
      const { done, value } = await reader.read();
      if (done) break;
      if (!value?.byteLength) continue;
      total += value.byteLength;
      if (total > maxBytes) {
        await reader.cancel('Learnitive HTTP response limit exceeded').catch(() => undefined);
        throw new Error(`HTTP response exceeded the ${maxBytes} byte Learnitive limit.`);
      }
      chunks.push(value);
    }
  } finally {
    reader.releaseLock();
  }

  const result = new Uint8Array(total);
  let offset = 0;
  for (const chunk of chunks) {
    result.set(chunk, offset);
    offset += chunk.byteLength;
  }
  return result;
}

/**
 * Browser-owned HTTP boundary used by the research terminal. It deliberately
 * exposes fetch semantics rather than a native curl process: only http(s), no
 * ambient browser credentials, bounded response bodies, no host filesystem,
 * and cancellation/timeout on every request.
 */
export class ProjectHttpService extends BaseService {
  static deps = [] as const;

  constructor(context: BaseServiceContext) {
    super(SERVICE_NAME.projectHttpService, context, {});
  }

  hookMount(): void {
    // Stateless boundary; lifecycle hook is explicit so service graph audits can
    // verify that every BaseService participates in the normal mount contract.
  }

  async request(input: ProjectHttpRequest, signal?: AbortSignal): Promise<ProjectHttpResponse> {
    const url = safeHttpUrl(input.url);
    const method = input.method ?? 'GET';
    if ((method === 'GET' || method === 'HEAD') && input.body !== undefined) {
      throw new Error(`${method} requests cannot carry a request body in the Learnitive browser HTTP client.`);
    }
    const bodyBytes = typeof input.body === 'string' ? new TextEncoder().encode(input.body).byteLength : input.body?.byteLength ?? 0;
    if (bodyBytes > MAX_REQUEST_BODY_BYTES) {
      throw new Error(`HTTP request body is ${bodyBytes} bytes, above the ${MAX_REQUEST_BODY_BYTES} byte Learnitive limit.`);
    }
    const timeoutMs = Math.max(1_000, Math.min(5 * 60_000, input.timeoutMs ?? DEFAULT_TIMEOUT_MS));
    const maxResponseBytes = Math.max(1, Math.min(512 * 1024 * 1024, input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES));
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(new Error(`HTTP request timed out after ${timeoutMs} ms.`)), timeoutMs);
    const abort = () => controller.abort(signal?.reason);
    signal?.addEventListener('abort', abort, { once: true });
    try {
      signal?.throwIfAborted();
      const requestBody = input.body instanceof Uint8Array ? Uint8Array.from(input.body).buffer : input.body;
      const response = await fetch(url, {
        method,
        headers: normalizedHeaders(input.headers),
        ...(method !== 'GET' && method !== 'HEAD' && requestBody !== undefined ? { body: requestBody } : {}),
        credentials: 'omit',
        redirect: 'follow',
        referrerPolicy: 'no-referrer',
        signal: controller.signal,
      });
      const bytes = method === 'HEAD' ? new Uint8Array() : await readBoundedBody(response, maxResponseBytes, controller.signal);
      const responseHeaders: Record<string, string> = {};
      response.headers.forEach((value, name) => { responseHeaders[name] = value; });
      const contentType = response.headers.get('content-type') ?? undefined;
      return {
        url: response.url || url.href,
        status: response.status,
        statusText: response.statusText,
        headers: responseHeaders,
        bytes,
        redirected: response.redirected,
        ...(contentType ? { contentType } : {}),
      };
    } catch (error) {
      if (controller.signal.aborted && !signal?.aborted) {
        const reason = controller.signal.reason;
        throw reason instanceof Error ? reason : new Error('HTTP request was aborted.');
      }
      if (error instanceof TypeError) {
        throw new Error(`Browser HTTP request failed. The destination may be offline or may not permit this browser origin (CORS): ${error.message}`);
      }
      throw error;
    } finally {
      clearTimeout(timeout);
      signal?.removeEventListener('abort', abort);
    }
  }
}
