import path from 'path';
import fsExtra from 'fs-extra';
import sharp from 'sharp';

const ICO_HEADER_SIZE = 6;
const ICO_DIR_ENTRY_SIZE = 16;
const ICO_TYPE_ICON = 1;
// Standard Windows icon sizes covering tray (16/24/32), taskbar (32/48),
// shell (48/256) and high-DPI (128/256). Issue #1190.
export const WIN_STANDARD_ICO_SIZES = [16, 24, 32, 48, 64, 128, 256] as const;

type IcoEntry = {
  index: number;
  width: number;
  height: number;
  bitCount: number;
  bytesInRes: number;
  imageOffset: number;
  directory: Buffer;
  data: Buffer;
};

function decodeDimension(value: number): number {
  return value === 0 ? 256 : value;
}

function compareByPreferredSize(
  preferredSize: number,
): (a: IcoEntry, b: IcoEntry) => number {
  return (a, b) => {
    const aSize = Math.max(a.width, a.height);
    const bSize = Math.max(b.width, b.height);

    const aExact = aSize === preferredSize ? 0 : 1;
    const bExact = bSize === preferredSize ? 0 : 1;
    if (aExact !== bExact) return aExact - bExact;

    const aDistance = Math.abs(aSize - preferredSize);
    const bDistance = Math.abs(bSize - preferredSize);
    if (aDistance !== bDistance) return aDistance - bDistance;

    const aSmaller = aSize < preferredSize ? 1 : 0;
    const bSmaller = bSize < preferredSize ? 1 : 0;
    if (aSmaller !== bSmaller) return aSmaller - bSmaller;

    if (a.bitCount !== b.bitCount) return b.bitCount - a.bitCount;
    if (aSize !== bSize) return bSize - aSize;

    return a.index - b.index;
  };
}

function parseIcoBuffer(buffer: Buffer): IcoEntry[] {
  if (buffer.length < ICO_HEADER_SIZE) {
    throw new Error('Invalid ICO: header too short.');
  }

  const reserved = buffer.readUInt16LE(0);
  const type = buffer.readUInt16LE(2);
  const count = buffer.readUInt16LE(4);

  if (reserved !== 0 || type !== ICO_TYPE_ICON || count < 1) {
    throw new Error('Invalid ICO: invalid header.');
  }

  const tableSize = ICO_HEADER_SIZE + count * ICO_DIR_ENTRY_SIZE;
  if (buffer.length < tableSize) {
    throw new Error('Invalid ICO: directory table too short.');
  }

  const entries: IcoEntry[] = [];

  for (let i = 0; i < count; i++) {
    const offset = ICO_HEADER_SIZE + i * ICO_DIR_ENTRY_SIZE;
    const widthByte = buffer.readUInt8(offset);
    const heightByte = buffer.readUInt8(offset + 1);
    const bitCount = buffer.readUInt16LE(offset + 6);
    const bytesInRes = buffer.readUInt32LE(offset + 8);
    const imageOffset = buffer.readUInt32LE(offset + 12);

    if (bytesInRes < 1 || imageOffset + bytesInRes > buffer.length) {
      throw new Error('Invalid ICO: frame out of bounds.');
    }

    entries.push({
      index: i,
      width: decodeDimension(widthByte),
      height: decodeDimension(heightByte),
      bitCount,
      bytesInRes,
      imageOffset,
      directory: buffer.subarray(offset, offset + ICO_DIR_ENTRY_SIZE),
      data: buffer.subarray(imageOffset, imageOffset + bytesInRes),
    });
  }

  return entries;
}

function buildReorderedIcoBuffer(
  buffer: Buffer,
  preferredSize: number,
): Buffer {
  const entries = parseIcoBuffer(buffer);
  const ordered = [...entries].sort(compareByPreferredSize(preferredSize));
  const count = ordered.length;
  const tableSize = ICO_HEADER_SIZE + count * ICO_DIR_ENTRY_SIZE;
  const payloadSize = ordered.reduce(
    (acc, entry) => acc + entry.data.length,
    0,
  );
  const output = Buffer.alloc(tableSize + payloadSize);

  output.writeUInt16LE(0, 0);
  output.writeUInt16LE(ICO_TYPE_ICON, 2);
  output.writeUInt16LE(count, 4);

  let currentOffset = tableSize;
  for (let i = 0; i < count; i++) {
    const entry = ordered[i];
    const entryOffset = ICO_HEADER_SIZE + i * ICO_DIR_ENTRY_SIZE;

    entry.directory.copy(output, entryOffset, 0, 8);
    output.writeUInt32LE(entry.data.length, entryOffset + 8);
    output.writeUInt32LE(currentOffset, entryOffset + 12);
    entry.data.copy(output, currentOffset);
    currentOffset += entry.data.length;
  }

  return output;
}

export async function writeIcoWithPreferredSize(
  sourcePath: string,
  outputPath: string,
  preferredSize: number,
): Promise<boolean> {
  try {
    const sourceBuffer = await fsExtra.readFile(sourcePath);
    const reordered = buildReorderedIcoBuffer(sourceBuffer, preferredSize);
    await fsExtra.ensureDir(path.dirname(outputPath));
    await fsExtra.outputFile(outputPath, reordered);
    return true;
  } catch {
    return false;
  }
}

/**
 * PNG signature `\x89PNG`. ICO frames may carry either a BMP DIB or an
 * embedded PNG payload (PNG-in-ICO, supported since Windows Vista).
 */
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47]);

function frameLooksLikePng(entry: IcoEntry): boolean {
  return (
    entry.data.length >= PNG_SIGNATURE.length &&
    entry.data.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)
  );
}

async function decodeFrameToPng(entry: IcoEntry): Promise<Buffer | null> {
  if (frameLooksLikePng(entry)) {
    return Buffer.from(entry.data);
  }
  // BMP DIB frames need to go through sharp's ico-to-PNG path, which only
  // works on the full ICO container. Fall back to letting the caller use a
  // sharp pipeline against the original ICO for the missing source.
  return null;
}

async function pickLargestFrameAsPng(
  buffer: Buffer,
  entries: IcoEntry[],
): Promise<Buffer | null> {
  const largest = [...entries].sort(
    (a, b) => Math.max(b.width, b.height) - Math.max(a.width, a.height),
  )[0];
  if (largest) {
    const decoded = await decodeFrameToPng(largest);
    if (decoded) {
      return decoded;
    }
  }

  // Fallback: let sharp render directly from the ICO buffer. sharp picks the
  // largest embedded frame on its own.
  try {
    return await sharp(buffer).png().toBuffer();
  } catch {
    return null;
  }
}

/**
 * Ensures the produced ICO carries every Windows standard size so the OS
 * never has to downsample a 256x256 frame to 16x16 for the tray.
 * Falls back to `writeIcoWithPreferredSize` if rendering fails.
 *
 * Issue #1190.
 */
export async function ensureMultiResolutionIco(
  sourcePath: string,
  outputPath: string,
  preferredSize: number = 256,
  desiredSizes: readonly number[] = WIN_STANDARD_ICO_SIZES,
): Promise<boolean> {
  try {
    const sourceBuffer = await fsExtra.readFile(sourcePath);
    const entries = parseIcoBuffer(sourceBuffer);

    const sourcePng = await pickLargestFrameAsPng(sourceBuffer, entries);
    if (!sourcePng) {
      return await writeIcoWithPreferredSize(
        sourcePath,
        outputPath,
        preferredSize,
      );
    }

    const frames = await Promise.all(
      desiredSizes.map(async (size) => {
        // Reuse an existing exact-size PNG frame when possible to keep any
        // hand-tuned small icon (e.g. a 16x16 with deliberate pixel hinting).
        const exact = entries.find(
          (entry) => entry.width === size && entry.height === size,
        );
        if (exact && frameLooksLikePng(exact)) {
          return { size, png: Buffer.from(exact.data) };
        }
        const png = await sharp(sourcePng)
          .resize(size, size, {
            fit: 'contain',
            background: { r: 0, g: 0, b: 0, alpha: 0 },
          })
          .ensureAlpha()
          .png()
          .toBuffer();
        return { size, png };
      }),
    );

    // Order frames so the preferred size lands first (Windows shell uses the
    // first-listed frame as a quality hint when choosing which to display).
    frames.sort((a, b) => {
      const aExact = a.size === preferredSize ? 0 : 1;
      const bExact = b.size === preferredSize ? 0 : 1;
      if (aExact !== bExact) return aExact - bExact;
      return b.size - a.size;
    });

    const icoBuffer = buildIcoFromPngBuffers(frames);
    await fsExtra.ensureDir(path.dirname(outputPath));
    await fsExtra.outputFile(outputPath, icoBuffer);
    return true;
  } catch {
    return await writeIcoWithPreferredSize(
      sourcePath,
      outputPath,
      preferredSize,
    );
  }
}

/**
 * Builds an ICO file from an array of PNG buffers using the PNG-in-ICO format
 * (supported since Windows Vista). This preserves alpha transparency.
 */
export function buildIcoFromPngBuffers(
  frames: Array<{ size: number; png: Buffer }>,
): Buffer {
  const count = frames.length;
  const headerSize = ICO_HEADER_SIZE + count * ICO_DIR_ENTRY_SIZE;
  const totalPayload = frames.reduce((acc, f) => acc + f.png.length, 0);
  const output = Buffer.alloc(headerSize + totalPayload);

  output.writeUInt16LE(0, 0);
  output.writeUInt16LE(ICO_TYPE_ICON, 2);
  output.writeUInt16LE(count, 4);

  let currentOffset = headerSize;
  for (let i = 0; i < count; i++) {
    const { size, png } = frames[i];
    const entryOffset = ICO_HEADER_SIZE + i * ICO_DIR_ENTRY_SIZE;
    const sizeByte = size >= 256 ? 0 : size;

    output.writeUInt8(sizeByte, entryOffset);
    output.writeUInt8(sizeByte, entryOffset + 1);
    output.writeUInt8(0, entryOffset + 2);
    output.writeUInt8(0, entryOffset + 3);
    output.writeUInt16LE(1, entryOffset + 4);
    output.writeUInt16LE(32, entryOffset + 6);
    output.writeUInt32LE(png.length, entryOffset + 8);
    output.writeUInt32LE(currentOffset, entryOffset + 12);

    png.copy(output, currentOffset);
    currentOffset += png.length;
  }

  return output;
}
