/**
 * Copyright (c) 2026 The xterm.js authors. All rights reserved.
 * @license MIT
 */

import { IDisposable } from '@xterm/xterm';
import { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';
import { ImageRenderer } from '../ImageRenderer';
import { CELL_SIZE_DEFAULT } from '../ImageStorage';
import { KittyImageStorage } from './KittyImageStorage';
import Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
import {
  KittyAction,
  KittyFormat,
  KittyCompression,
  IKittyCommand,
  IPendingTransmission,
  IKittyImageData,
  KittyPixelConstants,
  parseKittyCommand
} from './KittyGraphicsTypes';

const enum Constants {
  // Memory limit for base64 decoder (4MB, same as IIPHandler)
  DECODER_KEEP_DATA = 4194304,
  DECODER_INITIAL_DATA = 4194304, // 4MB
  // Local mirror of const enum (esbuild can't inline const enums from external packages)
  DECODER_OK = 0,
  // Maximum control data size
  MAX_CONTROL_DATA_SIZE = 512,
  // Semicolon codepoint
  SEMICOLON = 0x3B
}

const DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;

// Kitty graphics protocol handler with streaming base64 decoding.
export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {
  private _aborted = false;
  private _decodeError = false;

  private _activeDecoder: Base64Decoder | null = null;
  private readonly _maxEncodedBytes: number;
  private readonly _initialEncodedBytes: number;

  // Streaming related states

  // True while receiving control data (before semicolon).
  private _inControlData = true;

  // Buffer for control data.
  private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);
  private _controlLength = 0;

  // Pre-calculated encoded size limit
  private _encodedSizeLimit = 0;
  private _totalEncodedSize = 0;

  // Parsed command. These are the control data before semicolon.
  private _parsedCommand: IKittyCommand | null = null;

  // Storage related states

  private _pendingTransmissions: Map<number, IPendingTransmission> = new Map();
  // Tracks the pending key of the most recently started chunked upload.
  // Per spec, subsequent chunks only need m= (and optionally q=), without i=.
  // When a chunk arrives with no i=, this key is used to find the pending upload.
  private _lastPendingKey: number | undefined;

  constructor(
    private readonly _opts: IImageAddonOptions,
    private readonly _renderer: ImageRenderer,
    private readonly _kittyStorage: KittyImageStorage,
    private readonly _coreTerminal: ITerminalExt
  ) {
    // Convert decoded size limit -> max encoded bytes.
    this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);
    // ensure we preallocate more than configured limit while using 4mb initial size.
    this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);
  }

  public reset(): void {
    this._cleanupAllPending();
    if (this._activeDecoder) {
      this._activeDecoder.release();
      this._activeDecoder = null;
    }
    this._kittyStorage.reset();
  }

  public dispose(): void {
    this.reset();
  }

  private _removePendingEntry(key: number): void {
    this._pendingTransmissions.delete(key);
    if (this._lastPendingKey === key) {
      this._lastPendingKey = undefined;
    }
  }

  private _cleanupAllPending(): void {
    for (const pending of this._pendingTransmissions.values()) {
      pending.decoder.release();
    }
    this._pendingTransmissions.clear();
    this._lastPendingKey = undefined;
  }

  public start(): void {
    this._aborted = false;
    this._decodeError = false;
    this._inControlData = true;
    this._controlLength = 0;
    this._parsedCommand = null;
    // Pre-calculate encoded limit once: base64 is 4 bytes encoded → 3 bytes decoded
    this._encodedSizeLimit = this._maxEncodedBytes;
    this._totalEncodedSize = 0;
    this._activeDecoder = null;
  }

  public put(data: Uint32Array, start: number, end: number): void {
    if (this._aborted) return;

    if (!this._inControlData) {
      this._streamPayload(data, start, end);
    } else {
      // Scan for semicolon
      let controlEnd = end;
      for (let i = start; i < end; i++) {
        if (data[i] === Constants.SEMICOLON) {
          this._inControlData = false;
          controlEnd = i;
          break;
        }
      }

      // Copy control data
      const copyLength = controlEnd - start;
      if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {
        this._aborted = true;
        return;
      }
      this._controlData.set(data.subarray(start, controlEnd), this._controlLength);
      this._controlLength += copyLength;

      if (!this._inControlData) {
        // Found semicolon - parse control data early for validation
        this._parsedCommand = parseKittyCommand(this._parseControlDataString());

        // Early validation: i+I conflict
        if (this._parsedCommand.id !== undefined && this._parsedCommand.imageNumber !== undefined) {
          this._sendResponse(this._parsedCommand.id, 'EINVAL:cannot specify both i and I keys', this._parsedCommand.quiet ?? 0);
          this._aborted = true;
          return;
        }

        // Delete action doesn't need payload - skip streaming
        if (this._parsedCommand.action === KittyAction.DELETE) {
          return;
        }

        // Stream remaining as payload
        const payloadStart = controlEnd + 1;
        if (payloadStart < end) {
          this._streamPayload(data, payloadStart, end);
        }
      }
    }
  }

  // Stream payload bytes into the base64 decoder.
  private _streamPayload(data: Uint32Array, start: number, end: number): void {
    if (this._aborted) return;

    // Check size limit (compare encoded bytes against pre-calculated limit)
    // Include cumulative size from pending transmission for multi-chunk images.
    // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.
    const pendingKey = this._parsedCommand?.id ?? this._lastPendingKey ?? 0;
    const pending = this._pendingTransmissions.get(pendingKey);
    const previousEncodedSize = pending?.totalEncodedSize ?? 0;
    this._totalEncodedSize += end - start;
    const cumulativeEncodedSize = previousEncodedSize + this._totalEncodedSize;
    if (cumulativeEncodedSize > this._encodedSizeLimit) {
      const decoderToRelease = this._activeDecoder ?? pending?.decoder;
      if (decoderToRelease) {
        decoderToRelease.release();
      }
      this._activeDecoder = null;
      if (pending) {
        this._removePendingEntry(pendingKey);
      }
      this._aborted = true;
      return;
    }

    if (this._decodeError) return;

    if (pending?.decoder && !this._activeDecoder) {
      this._activeDecoder = pending.decoder;
    }
    if (!this._activeDecoder) {
      this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);
      this._activeDecoder.init();
    }

    if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {
      this._activeDecoder.release();
      this._activeDecoder = null;
      this._decodeError = true;
      if (pending) {
        this._removePendingEntry(pendingKey);
      }
    }
  }

  public end(success: boolean): boolean | Promise<boolean> {
    if (this._aborted || !success) {
      if (this._activeDecoder) {
        this._activeDecoder.release();
        this._activeDecoder = null;
      }
      return true;
    }

    // No semicolon = no payload (delete, capability query)
    if (this._inControlData) {
      return this._handleNoPayloadCommand();
    }

    // Use command parsed early in put() - i+I already validated there
    const cmd = this._parsedCommand!;

    // Delete action was handled by skipping payload - just execute
    if (cmd.action === KittyAction.DELETE) {
      return this._handleDelete(cmd);
    }

    // Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.
    const pendingKey = cmd.id ?? this._lastPendingKey ?? 0;
    const isMoreComing = cmd.more === 1;
    const pending = this._pendingTransmissions.get(pendingKey);

    if (isMoreComing) {
      if (this._activeDecoder) {
        if (pending) {
          pending.totalEncodedSize += this._totalEncodedSize;
          pending.decodeError = pending.decodeError || this._decodeError;
        } else {
          this._pendingTransmissions.set(pendingKey, {
            cmd: { ...cmd },
            decoder: this._activeDecoder,
            totalEncodedSize: this._totalEncodedSize,
            decodeError: this._decodeError
          });
        }
        this._lastPendingKey = pendingKey;
        this._activeDecoder = null;
      }
      return true;
    }

    // Final chunk received — clear the last pending key
    if (pending) {
      this._lastPendingKey = undefined;
    }

    let decodeError = this._decodeError;
    let finalCmd = cmd;
    let decoder = this._activeDecoder;

    if (pending) {
      finalCmd = pending.cmd;
      decoder = pending.decoder;
      decodeError = decodeError || pending.decodeError;
      this._pendingTransmissions.delete(pendingKey);
    }

    let imageBytes = new Uint8Array(0);
    if (decoder) {
      if (decoder.end() !== DECODER_OK) {
        decodeError = true;
      }
      imageBytes = decoder.data8;
    }
    this._activeDecoder = null;

    // Handle command first — handlers create Blob/ImageData from imageBytes,
    // which copies the data. Only then is it safe to release the decoder's
    // wasm memory that imageBytes points into.
    const result = this._handleCommandWithBytesAndCmd(finalCmd, imageBytes, decodeError);
    if (decoder) {
      decoder.release();
    }
    return result;
  }

  // Command handling

  private _parseControlDataString(): string {
    let str = '';
    for (let i = 0; i < this._controlLength; i++) {
      str += String.fromCodePoint(this._controlData[i]);
    }
    return str;
  }

  private _handleNoPayloadCommand(): boolean | Promise<boolean> {
    const cmd = parseKittyCommand(this._parseControlDataString());

    // Per spec: specifying both i and I is an error
    if (cmd.id !== undefined && cmd.imageNumber !== undefined) {
      this._sendResponse(cmd.id, 'EINVAL:cannot specify both i and I keys', cmd.quiet ?? 0);
      return true;
    }

    const action = cmd.action ?? 't';

    switch (action) {
      case KittyAction.DELETE:
        return this._handleDelete(cmd);
      case KittyAction.QUERY:
        this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);
        return true;
      case KittyAction.PLACEMENT:
        return this._handlePlacement(cmd);
      default:
        // TODO: Implement remaining actions when needed:
        // - a=f (frame): animation frame operations
        // - a=a (animation): animation control
        // - a=c (compose): compose images
        if (cmd.id !== undefined) {
          this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);
        }
        return true;
    }
  }

  private _handleCommandWithBytesAndCmd(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise<boolean> {
    const action = cmd.action ?? 't';

    switch (action) {
      case KittyAction.TRANSMIT: {
        const result = this._handleTransmit(cmd, bytes, decodeError);
        // Only send response when _handleTransmit didn't already respond
        // (it handles unsupported transmission medium responses internally)
        if ((cmd.transmission ?? 'd') === 'd' && cmd.id !== undefined) {
          if (decodeError) {
            this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);
          } else if (bytes.length > 0) {
            this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);
          }
        }
        return result;
      }
      case KittyAction.TRANSMIT_DISPLAY:
        return this._handleTransmitDisplay(cmd, bytes, decodeError);
      case KittyAction.QUERY:
        return this._handleQuery(cmd, bytes, decodeError);
      case KittyAction.PLACEMENT:
        // a=p ignores any payload — image data was already transmitted
        return this._handlePlacement(cmd);
      default:
        // TODO: Implement remaining actions when needed:
        // - a=f (frame): animation frame operations
        // - a=a (animation): animation control
        // - a=c (compose): compose images
        if (cmd.id !== undefined) {
          this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);
        }
        return true;
    }
  }

  private _handlePlacement(cmd: IKittyCommand): boolean | Promise<boolean> {
    if (cmd.id === undefined) {
      return true;
    }
    const id = cmd.id;
    const image = this._kittyStorage.getImage(id);
    if (!image) {
      this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);
      return true;
    }
    const result = this._displayImage(image, cmd);
    return result.then(success => {
      this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);
      return true;
    });
  }

  private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {
    // TODO: Support file-based transmission modes (t=f, t=t, t=s)
    // Currently only supports direct transmission (t=d, the default).
    // - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.
    // - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes.
    // - t=s: Payload is base64-encoded POSIX shm name. Terminal reads from shared memory.
    // These modes require filesystem/IPC access not available in browsers. For Node.js/Electron:
    // 1. Check cmd.transmission (t key) before treating bytes as image data
    // 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents
    // 3. For t=d: treat bytes as image data (current behavior)
    // When implementing, also update _handleQuery to accept these transmission mediums.
    const transmission = cmd.transmission ?? 'd';
    if (transmission !== 'd') {
      if (cmd.id !== undefined) {
        this._sendResponse(cmd.id, 'EINVAL:unsupported transmission medium', cmd.quiet ?? 0);
      }
      return true;
    }

    if (decodeError || bytes.length === 0) return true;

    this._kittyStorage.storeImage(cmd.id, {
      data: new Blob([bytes as BlobPart]),
      width: cmd.width ?? 0,
      height: cmd.height ?? 0,
      format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,
      compression: cmd.compression ?? ''
    });
    return true;
  }

  private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise<boolean> {
    if (decodeError) {
      if (cmd.id !== undefined) {
        this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);
      }
      return true;
    }

    this._handleTransmit(cmd, bytes, decodeError);

    const id = cmd.id ?? this._kittyStorage.lastImageId;
    const image = this._kittyStorage.getImage(id);
    if (image) {
      const result = this._displayImage(image, cmd);
      if (cmd.id !== undefined) {
        return result.then(success => {
          this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);
          return true;
        });
      }
      return result.then(() => true);
    }
    return true;
  }

  private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {
    const id = cmd.id ?? 0;
    const quiet = cmd.quiet ?? 0;

    // Per spec: reject unsupported transmission mediums (only t=d is supported atm)
    // TODO: When filesystem support is added (Node.js/Electron), update this to accept
    // t=f (file), t=t (temp file), and t=s (shared memory) and respond OK for queries.
    const transmission = cmd.transmission ?? 'd';
    if (transmission !== 'd') {
      this._sendResponse(id, 'EINVAL:unsupported transmission medium', quiet);
      return true;
    }

    // Check decode error first (invalid base64)
    if (decodeError) {
      this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);
      return true;
    }

    // Capability query (no payload) - just respond OK
    if (bytes.length === 0) {
      this._sendResponse(id, 'OK', quiet);
      return true;
    }

    const format = cmd.format ?? KittyFormat.RGBA;

    if (format === KittyFormat.PNG) {
      this._sendResponse(id, 'OK', quiet);
    } else {
      const width = cmd.width ?? 0;
      const height = cmd.height ?? 0;

      if (!width || !height) {
        this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);
        return true;
      }

      const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;
      const expectedBytes = width * height * bytesPerPixel;

      if (bytes.length < expectedBytes) {
        this._sendResponse(id, `EINVAL:insufficient pixel data`, quiet);
        return true;
      }

      this._sendResponse(id, 'OK', quiet);
    }
    return true;
  }

  private _handleDelete(cmd: IKittyCommand): boolean {
    // Per spec: default delete selector is 'a' (delete all visible placements)
    const selector = cmd.deleteSelector ?? 'a';

    // TODO: Distinguish lowercase (delete placements only) from uppercase
    // (delete placements + free stored image data). Currently both variants
    // free everything since we don't separate stored data from placements.
    switch (selector) {
      case 'a':
      case 'A':
        this._cleanupAllPending();
        this._kittyStorage.deleteAll();
        break;
      case 'i':
      case 'I':
        // TODO: When placement id tracking is implemented (see TODO in
        // KittyImageStorage), d=i with p=<pid> should delete only that
        // specific placement, while d=i without p should delete all
        // placements for the image.
        if (cmd.id !== undefined) {
          const pending = this._pendingTransmissions.get(cmd.id);
          if (pending) {
            pending.decoder.release();
          }
          this._removePendingEntry(cmd.id);
          this._kittyStorage.deleteById(cmd.id);
        }
        break;
      default:
        // Unsupported selectors (c, n, p, q, r, x, y, z, f) — ignore for now
        break;
    }
    return true;
  }

  private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {
    const isOk = message === 'OK';
    if (isOk && quiet >= 1) return;
    if (!isOk && quiet >= 2) return;

    const pPart = placementId ? `,p=${placementId}` : '';
    const response = `\x1b_Gi=${id}${pPart};${message}\x1b\\`;
    this._coreTerminal._core.coreService.triggerDataEvent(response);
  }

  // Image display

  private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise<boolean> {
    return this._decodeAndDisplay(image, cmd)
      .then(() => true)
      .catch(() => false);
  }

  private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise<void> {
    let bitmap: ImageBitmap | undefined = await this._createBitmap(image);

    try {
      const cropX = Math.max(0, cmd.x ?? 0);
      const cropY = Math.max(0, cmd.y ?? 0);
      const cropW = cmd.sourceWidth || (bitmap.width - cropX);
      const cropH = cmd.sourceHeight || (bitmap.height - cropY);

      const maxCropW = Math.max(0, bitmap.width - cropX);
      const maxCropH = Math.max(0, bitmap.height - cropY);
      const finalCropW = Math.max(0, Math.min(cropW, maxCropW));
      const finalCropH = Math.max(0, Math.min(cropH, maxCropH));

      if (finalCropW === 0 || finalCropH === 0) {
        throw new Error('invalid source rectangle');
      }

      if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {
        const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);
        bitmap.close();
        bitmap = cropped;
      }

      const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
      const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;

      // Per spec: c/r default to image's natural cell dimensions.
      // If only one of c/r is specified, compute the other from image aspect ratio.
      let imgCols: number;
      let imgRows: number;
      if (cmd.columns !== undefined && cmd.rows !== undefined) {
        imgCols = cmd.columns;
        imgRows = cmd.rows;
      } else if (cmd.columns !== undefined) {
        imgCols = cmd.columns;
        imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));
      } else if (cmd.rows !== undefined) {
        imgRows = cmd.rows;
        imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));
      } else {
        imgCols = Math.ceil(bitmap.width / cw);
        imgRows = Math.ceil(bitmap.height / ch);
      }

      let w = bitmap.width;
      let h = bitmap.height;

      // Scale bitmap to fit placement rectangle when c/r are specified
      if (cmd.columns !== undefined || cmd.rows !== undefined) {
        w = Math.round(imgCols * cw);
        h = Math.round(imgRows * ch);
      }

      if (w * h > this._opts.pixelLimit) {
        throw new Error('image exceeds pixel limit');
      }

      // Save cursor position before addImage modifies it
      const buffer = this._coreTerminal._core.buffer;
      const savedX = buffer.x;
      const savedY = buffer.y;
      const savedYbase = buffer.ybase;

      // Determine layer based on z-index: negative = behind text, 0+ = on top.
      // When z<0 we always use the bottom layer even without allowTransparency —
      // the image will simply be hidden behind the opaque text background, which
      // is the correct behavior (client asked for "behind text").
      const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;
      const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';

      if (w !== bitmap.width || h !== bitmap.height) {
        const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
        bitmap.close();
        bitmap = scaled;
      }

      // Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions
      const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);
      const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);
      if (xOffset !== 0 || yOffset !== 0) {
        // Per spec: X/Y is not added to c/r area. When c/r are explicit, the
        // total placement area remains c*cw × r*ch pixels and the offset image
        // is clipped to fit. When c/r are unset, the padded canvas determines
        // the natural cell dimensions.
        const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;
        const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;
        const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);
        const offsetCtx = offsetCanvas.getContext('2d');
        if (!offsetCtx) {
          throw new Error('Failed to create offset canvas context');
        }
        offsetCtx.drawImage(bitmap, xOffset, yOffset);

        const offsetBitmap = await createImageBitmap(offsetCanvas);
        offsetCanvas.width = offsetCanvas.height = 0;
        bitmap.close();
        bitmap = offsetBitmap;
        w = bitmap.width;
        h = bitmap.height;
        if (w * h > this._opts.pixelLimit) {
          throw new Error('image exceeds pixel limit');
        }
        if (cmd.columns === undefined) {
          imgCols = Math.ceil(bitmap.width / cw);
        }
        if (cmd.rows === undefined) {
          imgRows = Math.ceil(bitmap.height / ch);
        }
      }

      const zIndex = cmd.zIndex ?? 0;
      this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);
      bitmap = undefined;  // ownership transferred to storage

      // Kitty cursor movement
      // Per spec: cursor placed at first column after last image column,
      // on the last row of the image. C=1 means don't move cursor.
      if (cmd.cursorMovement === 1) {
        // C=1: restore cursor to position before image was placed
        const scrolled = buffer.ybase - savedYbase;
        buffer.x = savedX;
        // Can't restore cursor to scrollback?
        buffer.y = Math.max(savedY - scrolled, 0);
      } else {
        // Default (C=0): advance cursor horizontally past the image
        // addImage already positioned cursor on the last row via lineFeeds
        buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);
      }
    } catch (e) {
      bitmap?.close();
      throw e;
    }
  }

  // Create ImageBitmap from already-decoded image data.
  private async _createBitmap(image: IKittyImageData): Promise<ImageBitmap> {
    let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());

    if (image.compression === KittyCompression.ZLIB) {
      bytes = await this._decompressZlib(bytes);
    }

    if (image.format === KittyFormat.PNG) {
      const blob = new Blob([bytes as BlobPart], { type: 'image/png' });
      if (!window.createImageBitmap) {
        const url = URL.createObjectURL(blob);
        const img = new Image();
        return new Promise<ImageBitmap>((resolve, reject) => {
          img.addEventListener('load', () => {
            URL.revokeObjectURL(url);
            const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);
            canvas.getContext('2d')?.drawImage(img, 0, 0);
            createImageBitmap(canvas).then(resolve).catch(reject);
          });
          img.addEventListener('error', () => {
            URL.revokeObjectURL(url);
            reject(new Error('Failed to load image'));
          });
          img.src = url;
        });
      }
      return createImageBitmap(blob);
    }

    // Raw pixel data
    const width = image.width;
    const height = image.height;

    if (!width || !height) {
      throw new Error('Width and height required for raw pixel data');
    }

    const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;
    const expectedBytes = width * height * bytesPerPixel;

    if (bytes.length < expectedBytes) {
      throw new Error('Insufficient pixel data');
    }

    const pixelCount = width * height;

    if (image.format === KittyFormat.RGBA) {
      // RGBA: use bytes directly — no copy needed
      return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA), width, height));
    }

    // RGB→RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).
    // 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes — ~6x faster.
    // Assumes little-endian (all modern browsers/Node.js).
    const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA);
    const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
    const dst32 = new Uint32Array(data.buffer);
    const alignedPixels = pixelCount & ~3;  // round down to multiple of 4

    let srcOffset = 0;
    let dstOffset = 0;
    for (let i = 0; i < alignedPixels; i += 4) {
      const b0 = src32[srcOffset++];
      const b1 = src32[srcOffset++];
      const b2 = src32[srcOffset++];
      // Little-endian: pixel bytes are [R,G,B] → uint32 ABGR layout
      dst32[dstOffset++] = 0xFF000000 | b0;
      dst32[dstOffset++] = 0xFF000000 | (b0 >>> 24) | (b1 << 8);
      dst32[dstOffset++] = 0xFF000000 | (b1 >>> 16) | (b2 << 16);
      dst32[dstOffset++] = 0xFF000000 | (b2 >>> 8);
    }

    // Handle remaining 1–3 pixels
    let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB;
    let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA;
    for (let i = alignedPixels; i < pixelCount; i++) {
      data[dstByte]     = bytes[srcByte];
      data[dstByte + 1] = bytes[srcByte + 1];
      data[dstByte + 2] = bytes[srcByte + 2];
      data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE;
      srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;
      dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;
    }

    return createImageBitmap(new ImageData(data, width, height));
  }

  private async _decompressZlib(compressed: Uint8Array): Promise<Uint8Array> {
    try {
      return await this._decompress(compressed, 'deflate');
    } catch {
      return await this._decompress(compressed, 'deflate-raw');
    }
  }

  private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise<Uint8Array> {
    const ds = new DecompressionStream(format);
    const writer = ds.writable.getWriter();
    writer.write(compressed as BufferSource);
    writer.close();

    const chunks: Uint8Array[] = [];
    const reader = ds.readable.getReader();

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      chunks.push(value);
    }

    const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
    const result = new Uint8Array(totalLength);
    let offset = 0;
    for (const chunk of chunks) {
      result.set(chunk, offset);
      offset += chunk.length;
    }
    return result;
  }

  public get images(): ReadonlyMap<number, IKittyImageData> {
    return this._kittyStorage.images;
  }

  public get _kittyIdToStorageId(): ReadonlyMap<number, number> {
    return this._kittyStorage.kittyIdToStorageId;
  }

  public get pendingTransmissions(): ReadonlyMap<number, IPendingTransmission> {
    return this._pendingTransmissions;
  }
}
