import { assert, ErrorWithExtra, unreachable } from '../../../common/util/util.js';
import { kTextureFormatInfo, EncodableTextureFormat } from '../../format_info.js';
import { GPUTest } from '../../gpu_test.js';
import { numbersApproximatelyEqual } from '../conversion.js';
import { generatePrettyTable } from '../pretty_diff_tables.js';
import { reifyExtent3D, reifyOrigin3D } from '../unions.js';

import { fullSubrectCoordinates } from './base.js';
import { getTextureSubCopyLayout } from './layout.js';
import { kTexelRepresentationInfo, PerTexelComponent, TexelComponent } from './texel_data.js';
import { TexelView } from './texel_view.js';

type PerPixelAtLevel<T> = (coords: Required<GPUOrigin3DDict>) => T;

/** Threshold options for comparing texels of different formats (norm/float/int). */
export type TexelCompareOptions = {
  /** Threshold for integer texture formats. Defaults to 0. */
  maxIntDiff?: number;
  /** Threshold for non-integer (norm/float) texture formats, if not overridden. */
  maxFractionalDiff?: number;
  /** Threshold in ULPs for unorm/snorm texture formats. Overrides `maxFractionalDiff`. */
  maxDiffULPsForNormFormat?: number;
  /** Threshold in ULPs for float/ufloat texture formats. Overrides `maxFractionalDiff`. */
  maxDiffULPsForFloatFormat?: number;
};

export type PixelExpectation = PerTexelComponent<number> | Uint8Array;

export type PerPixelComparison<E extends PixelExpectation> = {
  coord: GPUOrigin3D;
  exp: E;
};

type TexelViewComparer = {
  /** Given coords, returns whether the two texel views are considered matching at that point. */
  predicate: PerPixelAtLevel<boolean>;
  /**
   * Given a list of failed coords, returns table rows for `generatePrettyTable` that
   * display the actual/expected values and diffs for debugging.
   */
  tableRows: (failedCoords: readonly Required<GPUOrigin3DDict>[]) => Iterable<string>[];
};

function makeTexelViewComparer(
  format: EncodableTextureFormat,
  { actTexelView, expTexelView }: { actTexelView: TexelView; expTexelView: TexelView },
  opts: TexelCompareOptions
): TexelViewComparer {
  const {
    maxIntDiff = 0,
    maxFractionalDiff,
    maxDiffULPsForNormFormat,
    maxDiffULPsForFloatFormat,
  } = opts;

  assert(maxIntDiff >= 0, 'threshold must be non-negative');
  if (maxFractionalDiff !== undefined) {
    assert(maxFractionalDiff >= 0, 'threshold must be non-negative');
  }
  if (maxDiffULPsForFloatFormat !== undefined) {
    assert(maxDiffULPsForFloatFormat >= 0, 'threshold must be non-negative');
  }
  if (maxDiffULPsForNormFormat !== undefined) {
    assert(maxDiffULPsForNormFormat >= 0, 'threshold must be non-negative');
  }

  const fmtIsInt = format.includes('int');
  const fmtIsNorm = format.includes('norm');
  const fmtIsFloat = format.includes('float');

  const tvc = {} as TexelViewComparer;
  if (fmtIsInt) {
    tvc.predicate = coords =>
      comparePerComponent(actTexelView.color(coords), expTexelView.color(coords), maxIntDiff);
  } else if (fmtIsNorm && maxDiffULPsForNormFormat !== undefined) {
    tvc.predicate = coords =>
      comparePerComponent(
        actTexelView.ulpFromZero(coords),
        expTexelView.ulpFromZero(coords),
        maxDiffULPsForNormFormat
      );
  } else if (fmtIsFloat && maxDiffULPsForFloatFormat !== undefined) {
    tvc.predicate = coords =>
      comparePerComponent(
        actTexelView.ulpFromZero(coords),
        expTexelView.ulpFromZero(coords),
        maxDiffULPsForFloatFormat
      );
  } else if (maxFractionalDiff !== undefined) {
    tvc.predicate = coords =>
      comparePerComponent(
        actTexelView.color(coords),
        expTexelView.color(coords),
        maxFractionalDiff
      );
  } else {
    if (fmtIsNorm) {
      unreachable('need maxFractionalDiff or maxDiffULPsForNormFormat to compare norm textures');
    } else if (fmtIsFloat) {
      unreachable('need maxFractionalDiff or maxDiffULPsForFloatFormat to compare float textures');
    } else {
      unreachable();
    }
  }

  const repr = kTexelRepresentationInfo[format];
  if (fmtIsInt) {
    tvc.tableRows = failedCoords => [
      [`tolerance ± ${maxIntDiff}`],
      (function* () {
        yield* [` diff (act - exp)`, '==', ''];
        for (const coords of failedCoords) {
          const act = actTexelView.color(coords);
          const exp = expTexelView.color(coords);
          yield repr.componentOrder.map(ch => act[ch]! - exp[ch]!).join(',');
        }
      })(),
    ];
  } else if (
    (fmtIsNorm && maxDiffULPsForNormFormat !== undefined) ||
    (fmtIsFloat && maxDiffULPsForFloatFormat !== undefined)
  ) {
    const toleranceULPs = fmtIsNorm ? maxDiffULPsForNormFormat! : maxDiffULPsForFloatFormat!;
    tvc.tableRows = failedCoords => [
      [`tolerance ± ${toleranceULPs} normal-ULPs`],
      (function* () {
        yield* [` diff (act - exp) in normal-ULPs`, '==', ''];
        for (const coords of failedCoords) {
          const act = actTexelView.ulpFromZero(coords);
          const exp = expTexelView.ulpFromZero(coords);
          yield repr.componentOrder.map(ch => act[ch]! - exp[ch]!).join(',');
        }
      })(),
    ];
  } else {
    assert(maxFractionalDiff !== undefined);
    tvc.tableRows = failedCoords => [
      [`tolerance ± ${maxFractionalDiff}`],
      (function* () {
        yield* [` diff (act - exp)`, '==', ''];
        for (const coords of failedCoords) {
          const act = actTexelView.color(coords);
          const exp = expTexelView.color(coords);
          yield repr.componentOrder.map(ch => (act[ch]! - exp[ch]!).toPrecision(4)).join(',');
        }
      })(),
    ];
  }

  return tvc;
}

function comparePerComponent(
  actual: PerTexelComponent<number>,
  expected: PerTexelComponent<number>,
  maxDiff: number
) {
  return Object.keys(actual).every(key => {
    const k = key as TexelComponent;
    const act = actual[k]!;
    const exp = expected[k];
    if (exp === undefined) return false;
    return numbersApproximatelyEqual(act, exp, maxDiff);
  });
}

/** Create a new mappable GPUBuffer, and copy a subrectangle of GPUTexture data into it. */
function createTextureCopyForMapRead(
  t: GPUTest,
  source: GPUImageCopyTexture,
  copySize: GPUExtent3D,
  { format }: { format: EncodableTextureFormat }
): { buffer: GPUBuffer; bytesPerRow: number; rowsPerImage: number } {
  const { byteLength, bytesPerRow, rowsPerImage } = getTextureSubCopyLayout(format, copySize, {
    aspect: source.aspect,
  });

  const buffer = t.device.createBuffer({
    usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
    size: byteLength,
  });
  t.trackForCleanup(buffer);

  const cmd = t.device.createCommandEncoder();
  cmd.copyTextureToBuffer(source, { buffer, bytesPerRow, rowsPerImage }, copySize);
  t.device.queue.submit([cmd.finish()]);

  return { buffer, bytesPerRow, rowsPerImage };
}

export function findFailedPixels(
  format: EncodableTextureFormat,
  subrectOrigin: Required<GPUOrigin3DDict>,
  subrectSize: Required<GPUExtent3DDict>,
  { actTexelView, expTexelView }: { actTexelView: TexelView; expTexelView: TexelView },
  texelCompareOptions: TexelCompareOptions,
  coords?: Generator<Required<GPUOrigin3DDict>>
) {
  const comparer = makeTexelViewComparer(
    format,
    { actTexelView, expTexelView },
    texelCompareOptions
  );

  const lowerCorner = [subrectSize.width, subrectSize.height, subrectSize.depthOrArrayLayers];
  const upperCorner = [0, 0, 0];
  const failedPixels: Required<GPUOrigin3DDict>[] = [];
  for (const coord of coords ?? fullSubrectCoordinates(subrectOrigin, subrectSize)) {
    const { x, y, z } = coord;
    if (!comparer.predicate(coord)) {
      failedPixels.push(coord);
      lowerCorner[0] = Math.min(lowerCorner[0], x);
      lowerCorner[1] = Math.min(lowerCorner[1], y);
      lowerCorner[2] = Math.min(lowerCorner[2], z);
      upperCorner[0] = Math.max(upperCorner[0], x);
      upperCorner[1] = Math.max(upperCorner[1], y);
      upperCorner[2] = Math.max(upperCorner[2], z);
    }
  }
  if (failedPixels.length === 0) {
    return undefined;
  }

  const info = kTextureFormatInfo[format];
  const repr = kTexelRepresentationInfo[format];

  const integerSampleType = info.sampleType === 'uint' || info.sampleType === 'sint';
  const numberToString = integerSampleType
    ? (n: number) => n.toFixed()
    : (n: number) => n.toPrecision(6);

  const componentOrderStr = repr.componentOrder.join(',') + ':';

  const printCoords = (function* () {
    yield* [' coords', '==', 'X,Y,Z:'];
    for (const coords of failedPixels) yield `${coords.x},${coords.y},${coords.z}`;
  })();
  const printActualBytes = (function* () {
    yield* [' act. texel bytes (little-endian)', '==', '0x:'];
    for (const coords of failedPixels) {
      yield Array.from(actTexelView.bytes(coords), b => b.toString(16).padStart(2, '0')).join(' ');
    }
  })();
  const printActualColors = (function* () {
    yield* [' act. colors', '==', componentOrderStr];
    for (const coords of failedPixels) {
      const pixel = actTexelView.color(coords);
      yield `${repr.componentOrder.map(ch => numberToString(pixel[ch]!)).join(',')}`;
    }
  })();
  const printExpectedColors = (function* () {
    yield* [' exp. colors', '==', componentOrderStr];
    for (const coords of failedPixels) {
      const pixel = expTexelView.color(coords);
      yield `${repr.componentOrder.map(ch => numberToString(pixel[ch]!)).join(',')}`;
    }
  })();
  const printActualULPs = (function* () {
    yield* [' act. normal-ULPs-from-zero', '==', componentOrderStr];
    for (const coords of failedPixels) {
      const pixel = actTexelView.ulpFromZero(coords);
      yield `${repr.componentOrder.map(ch => pixel[ch]).join(',')}`;
    }
  })();
  const printExpectedULPs = (function* () {
    yield* [` exp. normal-ULPs-from-zero`, '==', componentOrderStr];
    for (const coords of failedPixels) {
      const pixel = expTexelView.ulpFromZero(coords);
      yield `${repr.componentOrder.map(ch => pixel[ch]).join(',')}`;
    }
  })();

  const opts = {
    fillToWidth: 120,
    numberToString,
  };
  return `\
 between ${lowerCorner} and ${upperCorner} inclusive:
${generatePrettyTable(opts, [
  printCoords,
  printActualBytes,
  printActualColors,
  printExpectedColors,
  printActualULPs,
  printExpectedULPs,
  ...comparer.tableRows(failedPixels),
])}`;
}

/**
 * Check the contents of a GPUTexture by reading it back (with copyTextureToBuffer+mapAsync), then
 * comparing the data with the data in `expTexelView`.
 *
 * The actual and expected texture data are both converted to the "NormalULPFromZero" format,
 * which is a signed number representing how far the number is from zero, in ULPs, skipping
 * subnormal numbers (where ULP is defined for float, normalized, and integer formats).
 */
export async function textureContentIsOKByT2B(
  t: GPUTest,
  source: GPUImageCopyTexture,
  copySize_: GPUExtent3D,
  { expTexelView }: { expTexelView: TexelView },
  texelCompareOptions: TexelCompareOptions,
  coords?: Generator<Required<GPUOrigin3DDict>>
): Promise<ErrorWithExtra | undefined> {
  const subrectOrigin = reifyOrigin3D(source.origin ?? [0, 0, 0]);
  const subrectSize = reifyExtent3D(copySize_);
  const format = expTexelView.format;

  const { buffer, bytesPerRow, rowsPerImage } = createTextureCopyForMapRead(
    t,
    source,
    subrectSize,
    { format }
  );

  await buffer.mapAsync(GPUMapMode.READ);
  const data = new Uint8Array(buffer.getMappedRange());

  const texelViewConfig = {
    bytesPerRow,
    rowsPerImage,
    subrectOrigin,
    subrectSize,
  } as const;

  const actTexelView = TexelView.fromTextureDataByReference(format, data, texelViewConfig);

  const failedPixelsMessage = findFailedPixels(
    format,
    subrectOrigin,
    subrectSize,
    { actTexelView, expTexelView },
    texelCompareOptions,
    coords
  );

  if (failedPixelsMessage === undefined) {
    return undefined;
  }

  const msg = 'Texture level had unexpected contents:\n' + failedPixelsMessage;
  return new ErrorWithExtra(msg, () => ({
    expTexelView,
    // Make a new TexelView with a copy of the data so we can unmap the buffer (debug mode only).
    actTexelView: TexelView.fromTextureDataByReference(format, data.slice(), texelViewConfig),
  }));
}
