import { COLLABORATION_REVIEW_CAPABILITY_VERSION } from './collaboration-capabilities';

const FILE_CHANNEL_QUERY = 'channel=files';
const CHUNK_SIZE = 256 * 1024;
const REQUEST_TIMEOUT_MS = 60_000;
const MAX_BUFFERED_BYTES = 4 * 1024 * 1024;
const MAX_HEADER_BYTES = 64 * 1024;
const MAGIC = new Uint8Array([0x54, 0x58, 0x42, 0x31]); // TXB1
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();

export type CollaborationPayloadRequest = {
  requestId: string;
  path: string;
  sha256: string;
  size: number;
  preferredClientId?: number;
};

type PayloadChunkHeader = {
  requestId: string;
  path: string;
  sha256: string;
  size: number;
  index: number;
  total: number;
  sourceClientId: number;
};

type PendingRequest = {
  request: CollaborationPayloadRequest;
  chunks: Array<Uint8Array | undefined>;
  received: number;
  receivedBytes: number;
  total?: number;
  timer: ReturnType<typeof setTimeout>;
  resolve: (bytes: Uint8Array) => void;
  reject: (error: Error) => void;
};

function payloadUrl(roomKey: string, clientId: number): string {
  if (typeof window === 'undefined') return '';
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
  return `${protocol}//${window.location.host}/collab-live/${encodeURIComponent(roomKey)}?${FILE_CHANNEL_QUERY}&review=${COLLABORATION_REVIEW_CAPABILITY_VERSION}&client=${clientId}`;
}

function concatChunks(chunks: readonly Uint8Array[], size: number): Uint8Array {
  const result = new Uint8Array(size);
  let offset = 0;
  for (const chunk of chunks) {
    result.set(chunk, offset);
    offset += chunk.byteLength;
  }
  return result;
}

function encodeChunk(header: PayloadChunkHeader, chunk: Uint8Array): ArrayBuffer {
  const headerBytes = textEncoder.encode(JSON.stringify(header));
  const output = new Uint8Array(MAGIC.byteLength + 4 + headerBytes.byteLength + chunk.byteLength);
  output.set(MAGIC, 0);
  new DataView(output.buffer).setUint32(MAGIC.byteLength, headerBytes.byteLength, false);
  output.set(headerBytes, MAGIC.byteLength + 4);
  output.set(chunk, MAGIC.byteLength + 4 + headerBytes.byteLength);
  return output.buffer;
}

function decodeChunk(input: ArrayBuffer): { header: PayloadChunkHeader; chunk: Uint8Array } | undefined {
  const bytes = new Uint8Array(input);
  if (bytes.byteLength < MAGIC.byteLength + 4) return undefined;
  for (let index = 0; index < MAGIC.byteLength; index += 1) {
    if (bytes[index] !== MAGIC[index]) return undefined;
  }
  const headerLength = new DataView(input).getUint32(MAGIC.byteLength, false);
  const headerStart = MAGIC.byteLength + 4;
  const headerEnd = headerStart + headerLength;
  if (headerLength <= 0 || headerLength > MAX_HEADER_BYTES || headerEnd > bytes.byteLength) return undefined;
  try {
    const header = JSON.parse(textDecoder.decode(bytes.subarray(headerStart, headerEnd))) as Partial<PayloadChunkHeader>;
    if (
      typeof header.requestId !== 'string' ||
      typeof header.path !== 'string' ||
      typeof header.sha256 !== 'string' ||
      !Number.isSafeInteger(header.size) ||
      (header.size ?? -1) < 0 ||
      !Number.isInteger(header.index) ||
      !Number.isInteger(header.total) ||
      !Number.isSafeInteger(header.sourceClientId) ||
      (header.sourceClientId ?? -1) < 0 ||
      (header.total ?? 0) <= 0 ||
      (header.index ?? -1) < 0 ||
      (header.index ?? 0) >= (header.total ?? 0)
    ) return undefined;
    return {
      header: header as PayloadChunkHeader,
      chunk: bytes.slice(headerEnd),
    };
  } catch {
    return undefined;
  }
}

/**
 * Chunked, transient project-payload channel layered beside y-websocket.
 * Yjs remains authoritative for file-tree/metadata/text state; opaque/binary
 * payload bytes travel here so a large PDF/image/data file never inflates the
 * Y.Doc's initial Sync Step 2 into one oversized websocket frame.
 */
export class CollaborationPayloadChannel {
  private socket?: WebSocket;
  private connected = false;
  private destroyed = false;
  private connectPromise?: Promise<boolean>;
  private readonly requestListeners = new Set<(request: CollaborationPayloadRequest) => void>();
  private readonly pending = new Map<string, PendingRequest>();

  constructor(
    readonly roomKey: string,
    readonly clientId: number,
  ) {}

  async connect(timeoutMs = 10_000): Promise<boolean> {
    if (this.connected && this.socket?.readyState === WebSocket.OPEN) return true;
    if (this.connectPromise) return this.connectPromise;
    const promise = new Promise<boolean>((resolve) => {
      if (this.destroyed || typeof WebSocket === 'undefined' || !payloadUrl(this.roomKey, this.clientId)) {
        resolve(false);
        return;
      }
      const socket = new WebSocket(payloadUrl(this.roomKey, this.clientId));
      this.socket = socket;
      socket.binaryType = 'arraybuffer';
      let settled = false;
      const timer = setTimeout(() => {
        if (settled) return;
        settled = true;
        try { socket.close(); } catch { /* best effort */ }
        resolve(false);
      }, timeoutMs);
      socket.addEventListener('open', () => {
        this.connected = true;
        if (!settled) {
          settled = true;
          clearTimeout(timer);
          resolve(true);
        }
      });
      socket.addEventListener('message', this.onMessage);
      socket.addEventListener('close', () => {
        this.connected = false;
        if (this.socket === socket) this.socket = undefined;
        this.connectPromise = undefined;
        for (const entry of this.pending.values()) {
          clearTimeout(entry.timer);
          entry.reject(new Error('Project transfer connection closed'));
        }
        this.pending.clear();
      });
      socket.addEventListener('error', () => {
        if (!settled) {
          settled = true;
          clearTimeout(timer);
          resolve(false);
        }
      });
    });
    this.connectPromise = promise;
    const connected = await promise;
    if (!connected && this.connectPromise === promise) this.connectPromise = undefined;
    return connected;
  }

  subscribeRequests(listener: (request: CollaborationPayloadRequest) => void): () => void {
    this.requestListeners.add(listener);
    return () => this.requestListeners.delete(listener);
  }

  async requestFile(
    input: Omit<CollaborationPayloadRequest, 'requestId'>,
    timeoutMs?: number,
  ): Promise<Uint8Array> {
    if (!Number.isSafeInteger(input.size) || input.size < 0) throw new Error('Invalid project transfer size');
    if (input.preferredClientId !== undefined && (!Number.isSafeInteger(input.preferredClientId) || input.preferredClientId < 0)) {
      throw new Error('Invalid preferred collaboration client');
    }
    if (!(await this.connect())) throw new Error('Project transfer relay is unavailable');
    const socket = this.socket;
    if (!socket || socket.readyState !== WebSocket.OPEN) throw new Error('Project transfer relay is not connected');
    const request: CollaborationPayloadRequest = { ...input, requestId: crypto.randomUUID() };
    const effectiveTimeoutMs = timeoutMs ?? Math.max(REQUEST_TIMEOUT_MS, Math.ceil(input.size / (512 * 1024)) * 1_000);
    return new Promise<Uint8Array>((resolve, reject) => {
      const timer = setTimeout(() => {
        this.pending.delete(request.requestId);
        reject(new Error(`Timed out transferring ${request.path}`));
      }, effectiveTimeoutMs);
      this.pending.set(request.requestId, {
        request,
        chunks: [],
        received: 0,
        receivedBytes: 0,
        timer,
        resolve,
        reject,
      });
      socket.send(JSON.stringify({ type: 'request-file', ...request }));
    });
  }

  async sendFile(request: CollaborationPayloadRequest, bytes: Uint8Array): Promise<void> {
    const socket = this.socket;
    if (!socket || socket.readyState !== WebSocket.OPEN || bytes.byteLength !== request.size) return;
    const total = Math.max(1, Math.ceil(bytes.byteLength / CHUNK_SIZE));
    for (let index = 0; index < total; index += 1) {
      while (socket.readyState === WebSocket.OPEN && socket.bufferedAmount > MAX_BUFFERED_BYTES) {
        await new Promise((resolve) => setTimeout(resolve, 8));
      }
      if (socket.readyState !== WebSocket.OPEN) return;
      const start = index * CHUNK_SIZE;
      const end = Math.min(bytes.byteLength, start + CHUNK_SIZE);
      socket.send(encodeChunk({
        requestId: request.requestId,
        path: request.path,
        sha256: request.sha256,
        size: request.size,
        index,
        total,
        sourceClientId: this.clientId,
      }, bytes.subarray(start, end)));
    }
  }

  destroy(): void {
    if (this.destroyed) return;
    this.destroyed = true;
    this.requestListeners.clear();
    for (const entry of this.pending.values()) {
      clearTimeout(entry.timer);
      entry.reject(new Error('Project transfer session ended'));
    }
    this.pending.clear();
    try { this.socket?.close(); } catch { /* best effort */ }
    this.socket = undefined;
    this.connected = false;
  }

  private readonly onMessage = (event: MessageEvent<string | ArrayBuffer | Blob>) => {
    if (typeof event.data === 'string') {
      try {
        const message = JSON.parse(event.data) as Partial<CollaborationPayloadRequest> & { type?: string };
        if (
          message.type === 'request-file' &&
          typeof message.requestId === 'string' &&
          typeof message.path === 'string' &&
          typeof message.sha256 === 'string' &&
          Number.isSafeInteger(message.size) &&
          Number(message.size) >= 0 &&
          (message.preferredClientId === undefined || (Number.isSafeInteger(message.preferredClientId) && Number(message.preferredClientId) >= 0))
        ) {
          const request: CollaborationPayloadRequest = {
            requestId: message.requestId,
            path: message.path,
            sha256: message.sha256,
            size: Number(message.size),
            ...(message.preferredClientId === undefined ? {} : { preferredClientId: message.preferredClientId }),
          };
          if (request.preferredClientId !== undefined && request.preferredClientId !== this.clientId) return;
          for (const listener of this.requestListeners) listener(request);
        }
      } catch {
        // Ignore malformed control frames; the room key is private but payloads
        // are still treated as untrusted input.
      }
      return;
    }
    if (event.data instanceof Blob) {
      void event.data.arrayBuffer().then((buffer) => this.acceptChunk(buffer)).catch(() => undefined);
      return;
    }
    this.acceptChunk(event.data);
  };

  private acceptChunk(buffer: ArrayBuffer): void {
    const decoded = decodeChunk(buffer);
    if (!decoded) return;
    const pending = this.pending.get(decoded.header.requestId);
    if (!pending) return;
    if (
      decoded.header.path !== pending.request.path ||
      decoded.header.sha256 !== pending.request.sha256 ||
      decoded.header.size !== pending.request.size
    ) return;
    const expectedTotal = Math.max(1, Math.ceil(pending.request.size / CHUNK_SIZE));
    if (decoded.header.total !== expectedTotal) return;
    const expectedChunkSize = decoded.header.index === expectedTotal - 1
      ? Math.max(0, pending.request.size - (decoded.header.index * CHUNK_SIZE))
      : CHUNK_SIZE;
    if (decoded.chunk.byteLength !== expectedChunkSize) return;
    if (pending.total === undefined) {
      pending.total = decoded.header.total;
      pending.chunks = Array.from({ length: decoded.header.total });
    }
    if (pending.total !== decoded.header.total || pending.chunks[decoded.header.index]) return;
    pending.chunks[decoded.header.index] = decoded.chunk;
    pending.received += 1;
    pending.receivedBytes += decoded.chunk.byteLength;
    if (pending.received !== pending.total) return;
    const chunks = pending.chunks.filter((chunk): chunk is Uint8Array => Boolean(chunk));
    if (chunks.length !== pending.total || pending.receivedBytes !== pending.request.size) {
      clearTimeout(pending.timer);
      this.pending.delete(decoded.header.requestId);
      pending.reject(new Error(`Transferred project file had an invalid byte count: ${pending.request.path}`));
      return;
    }
    clearTimeout(pending.timer);
    this.pending.delete(decoded.header.requestId);
    pending.resolve(concatChunks(chunks, pending.request.size));
  }
}
