import { Deck, MapController, View, Viewport, type DeckProps } from "@deck.gl/core";
import type maplibregl from "maplibre-gl";

type MatrixViewState = {
  // MapLibre provides a full world->clip matrix as `modelViewProjectionMatrix`.
  // We treat it as the viewport's projection matrix and keep viewMatrix identity.
  projectionMatrix: number[];
  viewMatrix?: number[];
  // Deck's View state is constrained to include transition props. We only need one overlapping key
  // to satisfy TS structural checks without pulling in internal deck.gl types.
  transitionDuration?: number;
};

class MatrixView extends View<MatrixViewState> {
  getViewportType(viewState: MatrixViewState): typeof Viewport {
    void viewState;
    return Viewport;
  }

  // Controller isn't used (Deck is created with `controller: false`) but View requires one.
  protected get ControllerType(): typeof MapController {
    return MapController;
  }
}

const IDENTITY_4x4: number[] = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
type DeckLayerList = NonNullable<DeckProps<MatrixView[]>["layers"]>;

function readMat4(m: ArrayLike<number>): number[] {
  const out = new Array<number>(16);
  for (let i = 0; i < 16; i++) out[i] = m[i] as number;
  return out;
}

function mat4Changed(a: number[] | undefined, b: ArrayLike<number>): boolean {
  if (!a || a.length !== 16) return true;
  // The matrix values change on map move/rotate. A strict compare is fine.
  for (let i = 0; i < 16; i++) {
    if (a[i] !== (b[i] as number)) return true;
  }
  return false;
}

function getDeckLayerId(value: unknown): string | null {
  if (!value || typeof value !== "object") return null;
  const candidate = (value as { id?: unknown }).id;
  return typeof candidate === "string" ? candidate : null;
}

function sanitizeDeckLayers(value: unknown): DeckLayerList {
  if (!Array.isArray(value)) return [] as DeckLayerList;
  const out: DeckLayerList = [];
  const seen = new Set<string>();
  for (const item of value) {
    const layerId = getDeckLayerId(item);
    if (!layerId || seen.has(layerId)) continue;
    seen.add(layerId);
    out.push(item as DeckLayerList[number]);
  }
  return out;
}

export class MaplibreDeckCustomLayer implements maplibregl.CustomLayerInterface {
  id: string;
  type = "custom" as const;
  renderingMode = "3d" as const;

  private _map: maplibregl.Map | null = null;
  private _deck: Deck<MatrixView[]> | null = null;
  private _deckProps: Partial<DeckProps<MatrixView[]>> = {};
  private _viewId: string;
  private _lastMvp: number[] | undefined;
  private _finalizeOnRemove: boolean = false;

  constructor(opts: { id: string; viewId: string; deckProps?: Partial<DeckProps<MatrixView[]>> }) {
    this.id = opts.id;
    this._viewId = opts.viewId;
    this._deckProps = opts.deckProps ?? {};
  }

  get deck(): Deck<MatrixView[]> | null {
    return this._deck;
  }

  requestFinalize() {
    this._finalizeOnRemove = true;
  }

  setProps(next: Partial<DeckProps<MatrixView[]>>) {
    const normalized = next.layers ? { ...next, layers: sanitizeDeckLayers(next.layers) } : next;
    this._deckProps = { ...this._deckProps, ...normalized };
    if (this._deck) this._deck.setProps(this._deckProps as DeckProps<MatrixView[]>);
    this._map?.triggerRepaint();
  }

  onAdd(map: maplibregl.Map, gl: WebGLRenderingContext | WebGL2RenderingContext): void {
    this._finalizeOnRemove = false;
    this._map = map;

    if (this._deck) {
      // Re-attached after a style change; keep the existing Deck instance so we don't reuse
      // finalized Layer objects (Deck does not allow that).
      this._lastMvp = undefined;
      const nextDeckProps = {
        ...this._deckProps,
        layers: sanitizeDeckLayers(this._deckProps.layers),
        canvas: map.getCanvas(),
        // Ensure any pending redraw requests trigger a map repaint again.
        _customRender: () => map.triggerRepaint(),
      };
      this._deck.setProps(nextDeckProps as DeckProps<MatrixView[]>);
      return;
    }

    const deck = new Deck<MatrixView[]>({
      ...this._deckProps,
      layers: sanitizeDeckLayers(this._deckProps.layers),
      // Share MapLibre's WebGL context + canvas (single context).
      gl: gl as WebGL2RenderingContext,
      canvas: map.getCanvas(),
      width: null,
      height: null,
      // Let MapLibre own pointer/touch behaviors on the shared canvas.
      touchAction: "none",
      controller: false,
      views: this._deckProps.views ?? [new MatrixView({ id: this._viewId })],
      viewState: this._deckProps.viewState ?? { [this._viewId]: { projectionMatrix: IDENTITY_4x4 } },
      // Only request a repaint when Deck thinks it needs one. Drawing happens in `render()`.
      _customRender: () => map.triggerRepaint(),
      parameters: {
        // Match @deck.gl/mapbox interleaved defaults (premultiplied blending).
        depthWriteEnabled: true,
        depthCompare: "less-equal",
        depthBias: 0,
        blend: true,
        blendColorSrcFactor: "src-alpha",
        blendColorDstFactor: "one-minus-src-alpha",
        blendAlphaSrcFactor: "one",
        blendAlphaDstFactor: "one-minus-src-alpha",
        blendColorOperation: "add",
        blendAlphaOperation: "add",
        ...this._deckProps.parameters,
      },
    });

    this._deck = deck;
  }

  onRemove(): void {
    const deck = this._deck;
    const map = this._map;
    this._map = null;
    this._lastMvp = undefined;

    if (!deck) return;

    if (this._finalizeOnRemove) {
      deck.finalize();
      this._deck = null;
      return;
    }

    // Likely a base style swap; keep Deck instance alive and re-attach in onAdd().
    // Disable repaint requests until we get re-attached.
    try {
      deck.setProps({ _customRender: () => void 0 } as Partial<DeckProps<MatrixView[]>>);
    } catch {
      // ignore
    }
    try {
      map?.triggerRepaint();
    } catch {
      // ignore
    }
  }

  render(_gl: WebGLRenderingContext | WebGL2RenderingContext, options: maplibregl.CustomRenderMethodInput): void {
    const deck = this._deck;
    if (!this._map) return;
    if (!deck || !deck.isInitialized) return;
    // Deck reports `isInitialized` once `viewManager` exists, but we still see rare cases during
    // style/projection transitions where internal managers are temporarily null (or tearing down).
    // Guard before calling the internal `_drawLayers` to avoid crashing the whole map render.
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const internal = deck as any;
    if (!internal.layerManager || !internal.viewManager) return;

    // MapLibre gives us a world->clip matrix for the current projection (mercator/globe).
    // For globe, this matrix expects unit-sphere world coordinates (see MapLibre's globe transform).
    if (mat4Changed(this._lastMvp, options.modelViewProjectionMatrix)) {
      const projectionMatrix = readMat4(options.modelViewProjectionMatrix);
      this._lastMvp = projectionMatrix;
      deck.setProps({ viewState: { [this._viewId]: { projectionMatrix, viewMatrix: IDENTITY_4x4 } } });
    }

    try {
      deck._drawLayers("maplibre-custom", {
        clearCanvas: false,
        clearStack: true,
      });
    } catch (e) {
      // Rendering can fail transiently during style/projection transitions.
      // Keep the map responsive and request a clean pass on next frame.
      console.warn("Deck render sync failed, skipping frame:", e);
      requestAnimationFrame(() => {
        this._map?.triggerRepaint();
      });
    }
  }
}
