import { useTexture } from '@react-three/drei';
import { useMemo } from 'react';
import * as THREE from 'three';
import type { TTSObject } from '@tts/shared';
import {
  circleShape,
  extrudeShapeParts,
  traceToShape,
  traceToUvBounds,
  type ExtrudedGeometry,
} from '@tts/mesh';
import { traceImage } from '@tts/http';
import { assetUrl } from '@tts/http';
import {
  getSharedGeometry,
  getSharedMaterial,
  objectTint,
  tintKey,
  tintedColor,
} from './sharedResources';

const TOKEN_SIZE = 1.8;

/** How far (in trace pixels) the token silhouette is inset from the artwork. */
const TRACE_INSET = 2;

/**
 * The token mesh for an object, exported so the full-setup view can compose it
 * into a shared scene.
 */
export function TokenObjectMesh({ object }: { object: TTSObject }) {
  const url = object.CustomImage?.ImageURL ?? object.CustomImage?.ImageSecondaryURL;
  const thickness = object.CustomImage?.CustomToken?.Thickness ?? 0.1;

  return <TokenMesh url={url} thickness={thickness} tint={objectTint(object)} />;
}

// Rendered inside the Canvas so `useTexture` can access the R3F store.
// Exported so the full-setup view can compose it into a shared scene.
export function TokenMesh({
  url,
  thickness,
  tint,
}: {
  url?: string;
  thickness: number;
  tint: THREE.Color;
}) {
  const texture: THREE.Texture | null = url ? useTexture(assetUrl(url)) : null;

  // Token art is sRGB-encoded; `TextureLoader` leaves `colorSpace` as
  // `NoColorSpace`, which double-decodes it in the shader and washes out
  // contrast. Mark it sRGB so the GPU decodes it once, correctly.
  if (texture) texture.colorSpace = THREE.SRGBColorSpace;

  // Trace the image's alpha channel into a shape. Suspends until the trace
  // resolves so the surrounding Suspense boundary (and `Bounds`) only mounts
  // once the token geometry is present. Falls back to a circle when there's no
  // image or the trace fails.
  const trace = useTrace(url);

  const { front, back, walls } = useMemo(() => {
    // The traced shape and its UV framing share the same transform, so the
    // full image rectangle maps to the same bounds in mesh coordinates.
    const scale = TOKEN_SIZE / Math.max(trace?.width ?? 0, trace?.height ?? 0);
    const shape = trace ? traceToShape(trace, scale) : circleShape(TOKEN_SIZE / 2);
    const uvBounds = trace ? traceToUvBounds(trace, scale) : undefined;
    const parts = extrudeShapeParts(shape, { height: thickness, uvBounds });
    // Shared across tokens with the same source image (the trace is cached per
    // URL, so the silhouette is deterministic) so the full-setup view reuses
    // geometry.
    const key = `token:${url ?? 'none'}:${thickness}`;
    return {
      front: getSharedGeometry(key + ':front', () => toGeometry(parts.front)),
      back: getSharedGeometry(key + ':back', () => toGeometry(parts.back)),
      walls: getSharedGeometry(key + ':walls', () => toGeometry(parts.walls)),
    };
  }, [trace, thickness, url]);

  // A token is solid: front, back, and walls all carry the texture (projected
  // UV), unlike tiles/cards where only the faces are textured. The tint is
  // baked into the color and cache key so tinted variants don't collide.
  const material = getSharedMaterial(`token:${url ?? 'none'}:${tintKey(tint)}`, {
    color: tintedColor(texture ? new THREE.Color('#ffffff') : new THREE.Color('#52525b'), tint),
    map: texture ?? undefined,
    roughness: 0.8,
  });

  return (
    <group>
      <mesh geometry={front} material={material} />
      <mesh geometry={back} material={material} />
      <mesh geometry={walls} material={material} />
    </group>
  );
}

interface TraceData {
  shape: { outline: number[][]; holes?: number[][][] };
  width: number;
  height: number;
}

// Cache traces by URL so Suspense doesn't re-issue the request on every render
// while the boundary is held open. A URL maps to either a pending promise (while
// loading) or the resolved value (once loaded).
const traceCache = new Map<string, TraceData | null | Promise<TraceData | null>>();

/**
 * Suspend on the alpha trace for `url`, resolving to the traced shape (or null
 * when there's no URL / the trace fails). Throws the cached promise only while
 * it's pending; once resolved, the value is returned directly so the retry
 * render completes instead of suspending forever.
 */
function useTrace(url: string | undefined): TraceData | null {
  if (!url) return null;
  const cached = traceCache.get(url);
  if (cached === undefined) {
    const promise = traceImage(url, 'alpha', -TRACE_INSET).then((result) => {
      const value: TraceData | null = result.shape
        ? {
            shape: result.shape,
            width: result.width,
            height: result.height,
          }
        : null;
      // Replace the pending promise with the resolved value so later renders
      // return it instead of re-suspending on a settled promise.
      traceCache.set(url, value);
      return value;
    });
    traceCache.set(url, promise);
    throw promise;
  }
  if (cached instanceof Promise) throw cached;
  return cached;
}

/** Convert raw extruded arrays into a three.js `BufferGeometry`. */
function toGeometry(extruded: ExtrudedGeometry) {
  const { positions, normals, uvs, indices } = extruded;
  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  geo.setAttribute('normal', new THREE.BufferAttribute(normals, 3));
  geo.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));
  geo.setIndex(new THREE.BufferAttribute(indices, 1));
  return geo;
}