import type OlMap from "ol/Map";
import WebGLVectorTileLayer from "ol/layer/WebGLVectorTile";
import type VectorTileSource from "ol/source/VectorTile";
import type { FlatStyleLike, StyleVariables } from "ol/style/flat";
import {
  getLoadedVectorTiles,
  getVectorTileFeatureId,
  getVectorTileFeatureProperties,
} from "./vectorTileUtils";

type FeatureStateValue = string | number | boolean | null;
type StyleBuilder = (
  propertyKey: string,
  versionKey: string,
  version: number,
) => FlatStyleLike;

type VectorTileStyleSessionOptions = {
  layer: WebGLVectorTileLayer;
  source: VectorTileSource;
  propertyKey: string;
  defaultStyle: FlatStyleLike;
  buildStyle: StyleBuilder;
  map?: OlMap;
  buffered?: boolean;
  variables?: StyleVariables;
};

const INTERNAL_BUFFER_LAYER = "internalRenderBuffer";
const BUFFER_READY_TIMEOUT_MS = 5000;

export class VectorTileStyleSession {
  private readonly layer: WebGLVectorTileLayer;
  private readonly source: VectorTileSource;
  readonly propertyKey: string;
  private readonly versionKey: string;
  private readonly defaultStyle: FlatStyleLike;
  private readonly map?: OlMap;
  private readonly buffered: boolean;
  private buildStyle: StyleBuilder;
  private styleVariables: StyleVariables;
  private unbufferedState = new Map<string, FeatureStateValue>();
  private version = 0;
  private disposed = false;
  private commitRevision = 0;
  private bufferedCommitQueue: Promise<void> = Promise.resolve();
  private readonly slotStates = [
    new Map<string, FeatureStateValue>(),
    new Map<string, FeatureStateValue>(),
  ];
  private readonly slotVersions = [0, 0];
  private activeSlot = 0;
  private activeLayer: WebGLVectorTileLayer;
  private bufferLayer: WebGLVectorTileLayer | null = null;
  private readonly displayOpacity: number;
  private readonly listener: (event: any) => void;
  private readonly visibilityListener: () => void;

  constructor(options: VectorTileStyleSessionOptions) {
    this.layer = options.layer;
    this.source = options.source;
    this.propertyKey = sanitizeStyleIdentifier(options.propertyKey);
    this.versionKey = `${this.propertyKey}_render_version`;
    this.defaultStyle = options.defaultStyle;
    this.buildStyle = options.buildStyle;
    this.styleVariables = { ...(options.variables || {}) };
    this.map = options.map;
    this.buffered = Boolean(options.buffered && options.map);
    this.activeLayer = this.layer;
    this.displayOpacity = this.layer.getOpacity();
    this.listener = (event: any) => {
      try {
        if (typeof event.tile?.getFeatures !== "function") return;
        this.applyTile(event.tile);
      } catch (error) {
        console.error("Vector tile style session load error:", error);
      }
    };
    this.visibilityListener = () => {
      this.bufferLayer?.setVisible(this.layer.getVisible());
    };
    this.source.on("tileloadend", this.listener);
    this.layer.on("change:visible", this.visibilityListener);
  }

  commit(
    stateById: ReadonlyMap<string, FeatureStateValue>,
  ): Promise<boolean> | void {
    if (this.disposed) return;
    if (this.buffered) {
      return this.commitBuffered(stateById, false);
    }

    this.version += 1;
    this.unbufferedState = this.normalizeState(stateById);
    this.applyLoadedTiles();
    this.layer.setStyle(
      this.buildStyle(this.propertyKey, this.versionKey, this.version),
    );
  }

  setBuildStyle(buildStyle: StyleBuilder) {
    this.buildStyle = buildStyle;
  }

  updateStyleVariables(variables: StyleVariables) {
    if (this.disposed) return;
    this.styleVariables = { ...this.styleVariables, ...variables };
    this.layer.updateStyleVariables(variables);
    this.bufferLayer?.updateStyleVariables(variables);
  }

  reset(): Promise<boolean> | void {
    if (this.disposed) return;
    if (this.buffered) {
      return this.commitBuffered(new Map(), true);
    }
    this.version += 1;
    this.unbufferedState = new Map();
    this.applyLoadedTiles();
    this.layer.setStyle(this.defaultStyle);
  }

  dispose() {
    if (this.disposed) return;
    this.commitRevision += 1;
    this.source.un("tileloadend", this.listener);
    this.layer.un("change:visible", this.visibilityListener);
    if (this.bufferLayer && this.map) {
      this.map.removeLayer(this.bufferLayer);
      this.bufferLayer.dispose();
      this.bufferLayer = null;
    }
    this.layer.setOpacity(this.displayOpacity);
    this.disposed = true;
  }

  private normalizeState(stateById: ReadonlyMap<string, FeatureStateValue>) {
    return new Map(
      Array.from(stateById.entries()).map(([id, value]) => [String(id), value]),
    );
  }

  private getSlotPropertyKey(slot: number) {
    return `${this.propertyKey}_buffer_${slot}`;
  }

  private getSlotVersionKey(slot: number) {
    return `${this.versionKey}_buffer_${slot}`;
  }

  private commitBuffered(
    stateById: ReadonlyMap<string, FeatureStateValue>,
    useDefaultStyle: boolean,
  ) {
    if (!this.map || this.disposed) return Promise.resolve(false);
    const revision = this.commitRevision + 1;
    this.commitRevision = revision;
    const normalizedState = this.normalizeState(stateById);
    const commitPromise = this.bufferedCommitQueue.then(() => {
      if (this.disposed || revision !== this.commitRevision) return false;
      return this.prepareBufferedCommit(
        normalizedState,
        useDefaultStyle,
        revision,
      );
    });
    this.bufferedCommitQueue = commitPromise.then(
      () => undefined,
      () => undefined,
    );

    // Wake a pending readiness check so an obsolete build can exit before the
    // next style replaces its renderer.
    this.map.render();
    return commitPromise;
  }

  private async prepareBufferedCommit(
    stateById: Map<string, FeatureStateValue>,
    useDefaultStyle: boolean,
    revision: number,
  ) {
    if (!this.map || this.disposed || revision !== this.commitRevision) {
      return false;
    }
    const targetSlot = this.activeSlot === 0 ? 1 : 0;
    this.version += 1;
    this.slotVersions[targetSlot] = this.version;
    this.slotStates[targetSlot] = stateById;
    this.applyLoadedTiles();

    const standbyLayer = this.getStandbyLayer();
    standbyLayer.setVisible(this.layer.getVisible());
    standbyLayer.setOpacity(0);
    standbyLayer.setStyle(
      useDefaultStyle
        ? this.defaultStyle
        : this.buildStyle(
            this.getSlotPropertyKey(targetSlot),
            this.getSlotVersionKey(targetSlot),
            this.version,
          ),
    );
    this.map.render();

    const ready = await this.waitUntilReady(standbyLayer, revision);
    if (!ready || this.disposed || revision !== this.commitRevision) {
      return false;
    }

    const previousLayer = this.activeLayer;
    standbyLayer.setOpacity(this.displayOpacity);
    previousLayer.setOpacity(0);
    this.activeLayer = standbyLayer;
    this.activeSlot = targetSlot;
    this.map.render();
    return true;
  }

  private getStandbyLayer(): WebGLVectorTileLayer {
    const map = this.map;
    if (!map) return this.layer;
    if (!this.bufferLayer) {
      const bufferLayer = new WebGLVectorTileLayer<any, any>({
        source: this.source,
        style: this.defaultStyle,
        extent: this.layer.getExtent(),
        minZoom: this.layer.getMinZoom(),
        maxZoom: this.layer.getMaxZoom(),
        minResolution: this.layer.getMinResolution(),
        maxResolution: this.layer.getMaxResolution(),
        opacity: 0,
        visible: this.layer.getVisible(),
        variables: this.styleVariables,
        properties: {
          [INTERNAL_BUFFER_LAYER]: true,
          queryable: false,
        },
      });
      bufferLayer.setPreload(this.layer.getPreload());
      const zIndex = this.layer.getZIndex();
      if (zIndex !== undefined) bufferLayer.setZIndex(zIndex);
      const layers = map.getLayers();
      const baseIndex = layers.getArray().indexOf(this.layer);
      layers.insertAt(baseIndex >= 0 ? baseIndex + 1 : layers.getLength(), bufferLayer);
      this.bufferLayer = bufferLayer;
    }
    return this.activeLayer === this.layer ? this.bufferLayer! : this.layer;
  }

  private waitUntilReady(layer: WebGLVectorTileLayer, revision: number) {
    if (!this.map) return Promise.resolve(true);
    return new Promise<boolean>((resolve) => {
      let consecutiveReadyFrames = 0;
      let settled = false;
      const finish = (ready: boolean) => {
        if (settled) return;
        settled = true;
        window.clearTimeout(timeoutId);
        this.map?.un("postrender", checkReady);
        resolve(ready);
      };
      const checkReady = () => {
        if (this.disposed || revision !== this.commitRevision) {
          finish(false);
          return;
        }
        const renderer = layer.getRenderer() as any;
        consecutiveReadyFrames = renderer?.renderComplete
          ? consecutiveReadyFrames + 1
          : 0;
        if (consecutiveReadyFrames >= 2) {
          finish(true);
          return;
        }
        this.map?.render();
      };
      const timeoutId = window.setTimeout(
        () => finish(false),
        BUFFER_READY_TIMEOUT_MS,
      );
      this.map!.on("postrender", checkReady);
      this.map!.render();
    });
  }

  private applyLoadedTiles() {
    getLoadedVectorTiles(this.source).forEach((tile) => this.applyTile(tile));
  }

  private applyTile(tile: any) {
    if (this.buffered) {
      this.slotVersions.forEach((version, slot) => {
        if (version > 0) {
          this.applyTileState(
            tile,
            this.slotStates[slot],
            this.getSlotPropertyKey(slot),
            this.getSlotVersionKey(slot),
            version,
          );
        }
      });
      return;
    }
    this.applyTileState(
      tile,
      this.unbufferedState,
      this.propertyKey,
      this.versionKey,
      this.version,
    );
  }

  private applyTileState(
    tile: any,
    stateById: ReadonlyMap<string, FeatureStateValue>,
    propertyKey: string,
    versionKey: string,
    version: number,
  ) {
    const renderFeatures = tile.getFeatures?.();
    if (!renderFeatures || renderFeatures.length === 0) return;
    renderFeatures.forEach((renderFeature: any) => {
      const featureId = getVectorTileFeatureId(renderFeature);
      if (!featureId) return;
      const properties = getVectorTileFeatureProperties(renderFeature);
      const value = stateById.get(featureId);
      if (value === undefined) {
        delete properties[propertyKey];
        properties[versionKey] = version;
        return;
      }
      properties[propertyKey] = value;
      properties[versionKey] = version;
    });
  }
}

export const sanitizeStyleIdentifier = (value: string) => {
  const normalized = value
    .replace(/[^A-Za-z0-9_]/g, "_")
    .replace(/_+/g, "_")
    .replace(/^_+|_+$/g, "");
  const prefixed = /^[A-Za-z]/.test(normalized) ? normalized : `tj_${normalized}`;
  return prefixed || "tj_state";
};

export const versionedPropertyCase = (
  propertyKey: string,
  versionKey: string,
  version: number,
  cases: any[],
  fallback: any,
) => [
  "case",
  ["all", ["==", ["get", versionKey], version], ["has", propertyKey]],
  ["case", ...cases, fallback],
  fallback,
];

const remapFlatStyleProperty = (
  style: FlatStyleLike,
  fromProperty: string,
  toProperty: string,
): FlatStyleLike => {
  const remap = (value: any): any => {
    if (Array.isArray(value)) {
      const next = value.map(remap);
      if (
        (next[0] === "get" || next[0] === "has") &&
        next[1] === fromProperty
      ) {
        next[1] = toProperty;
      }
      return next;
    }
    if (value && typeof value === "object") {
      return Object.fromEntries(
        Object.entries(value).map(([key, nested]) => [key, remap(nested)]),
      );
    }
    return value;
  };
  return remap(style) as FlatStyleLike;
};

export const buildVersionedFlatStyle = (
  style: FlatStyleLike,
  fallbackStyle: FlatStyleLike,
  sourcePropertyKey: string,
  propertyKey: string,
  versionKey: string,
  version: number,
): FlatStyleLike => {
  const remappedStyle = remapFlatStyleProperty(
    style,
    sourcePropertyKey,
    propertyKey,
  );
  const guarded: Record<string, any> = {};
  Object.entries(remappedStyle as Record<string, any>).forEach(
    ([key, value]) => {
      guarded[key] = [
        "case",
        ["all", ["==", ["get", versionKey], version], ["has", propertyKey]],
        value,
        (fallbackStyle as Record<string, any>)[key] ?? value,
      ];
    },
  );
  return guarded as FlatStyleLike;
};
