/*
 * Copyright (c) 2015-2018, IGN France.
 * Copyright (c) 2018-2026, Giro3D team.
 * SPDX-License-Identifier: MIT
 */

import {
    CanvasTexture,
    FloatType,
    GLSL3,
    ShaderMaterial,
    Uniform,
    type AnyPixelFormat,
    type IUniform,
    type Texture,
    type TextureDataType,
} from 'three';

import Interpretation, { Mode, type InterpretationUniform } from '../../core/layer/Interpretation';
import TextureGenerator from '../../utils/TextureGenerator';
import FragmentShader from './ComposerTileFS.glsl';
import VertexShader from './ComposerTileVS.glsl';
// Matches the NoDataOptions struct in the shader
interface NoDataOptions {
    replacementAlpha: number;
    radius: number;
    enabled: boolean;
}

export interface Options {
    texture: Texture;
    interpretation: Interpretation;
    flipY: boolean;
    noDataOptions: NoDataOptions;
    showImageOutlines: boolean;
    showEmptyTexture: boolean;
    transparent: boolean;
    expandRGB: boolean;
    convertRGFloatToRGBAUnsignedByte: { precision: number; offset: number } | null;
}

function createGridTexture(): CanvasTexture {
    const canvas = document.createElement('canvas');
    canvas.width = 512;
    canvas.height = 512;
    const w = canvas.width;
    const h = canvas.height;

    const ctx = canvas.getContext('2d', { willReadFrequently: true });

    if (!ctx) {
        throw new Error('could not acquire 2D rendering context');
    }

    const back = 'black';
    const fore = 'yellow';
    const borderWidth = 4;
    const lineWidth = 3;

    ctx.strokeStyle = back;
    ctx.lineWidth = lineWidth + 2 * borderWidth;
    ctx.strokeRect(0, 0, w, h);

    ctx.strokeStyle = fore;
    ctx.lineWidth = lineWidth;
    ctx.strokeRect(0, 0, w, h);

    ctx.strokeStyle = fore;
    ctx.setLineDash([8, 8]);
    ctx.lineWidth = 2;
    const subdivs = 2;
    const xWidth = w / subdivs;

    for (let i = 1; i < subdivs; i++) {
        const x = i * xWidth;
        ctx.moveTo(x, 0);
        ctx.lineTo(x, h);
        ctx.stroke();
    }

    const yWidth = h / subdivs;
    for (let i = 1; i < subdivs; i++) {
        const y = i * yWidth;
        ctx.moveTo(0, y);
        ctx.lineTo(w, y);
        ctx.stroke();
    }

    // Center of the image

    const radius = 4;
    const centerX = w / 2;
    const centerY = h / 2;

    ctx.fillStyle = back;
    ctx.beginPath();
    ctx.ellipse(centerX, centerY, radius + borderWidth, radius + borderWidth, 0, 0, 2 * Math.PI);
    ctx.fill();

    ctx.fillStyle = fore;
    ctx.beginPath();
    ctx.ellipse(centerX, centerY, radius, radius, 0, 0, 2 * Math.PI);
    ctx.fill();

    return new CanvasTexture(canvas);
}

const POOL: unknown[] = [];
const POOL_SIZE = 2048;
let GRID_TEXTURE: Texture;

type Uniforms = {
    tex: IUniform<Texture | null>;
    gridTexture: IUniform<Texture | null>;
    flipY: IUniform<boolean>;
    showImageOutlines: IUniform<boolean>;
    expandRGB: IUniform<boolean>;
    opacity: IUniform<number>;
    channelCount: IUniform<number>;
    showEmptyTexture: IUniform<boolean>;
    isEmptyTexture: IUniform<boolean>;
    noDataOptions: IUniform<NoDataOptions>;
    interpretation: IUniform<InterpretationUniform>;
    convertRGFloatToRGBAUnsignedByte: IUniform<boolean>;
    heightPrecision: IUniform<number>;
    heightOffset: IUniform<number>;
} & Record<string, IUniform>;

class ComposerTileMaterial extends ShaderMaterial {
    public readonly isComposerTileMaterial = true as const;

    public dataType?: TextureDataType;
    public pixelFormat?: AnyPixelFormat;

    public override readonly uniforms: Uniforms;

    /**
     * Creates an instance of ComposerTileMaterial.
     *
     * @param options - The options
     */
    public constructor(options: Options) {
        super({ glslVersion: GLSL3 });

        this.fragmentShader = FragmentShader;
        this.vertexShader = VertexShader;

        this.depthTest = false;

        this.uniforms = {
            tex: new Uniform(null),
            gridTexture: new Uniform(null),
            interpretation: new Uniform({ max: 1, min: 0, mode: 0, negateValues: false }),
            flipY: new Uniform(false),
            noDataOptions: new Uniform({ enabled: false, radius: 0, replacementAlpha: 0 }),
            showImageOutlines: new Uniform(false),
            opacity: new Uniform(this.opacity),
            channelCount: new Uniform(3),
            expandRGB: new Uniform(options.expandRGB ?? false),
            showEmptyTexture: new Uniform(options.showEmptyTexture ?? false),
            isEmptyTexture: new Uniform(false),
            convertRGFloatToRGBAUnsignedByte: new Uniform(
                options.convertRGFloatToRGBAUnsignedByte != null,
            ),
            heightPrecision: new Uniform(
                options.convertRGFloatToRGBAUnsignedByte?.precision ?? 0.1,
            ),
            heightOffset: new Uniform(options.convertRGFloatToRGBAUnsignedByte?.offset ?? 20000),
        };

        if (options != null) {
            this.init(options);
        }
    }

    private init(options: Options): void {
        const interp = options.interpretation ?? Interpretation.Raw;

        this.dataType = interp.mode !== Mode.Raw ? FloatType : options.texture.type;
        this.pixelFormat = options.texture.format;

        const interpValue = {} as InterpretationUniform;
        interp.setUniform(interpValue);

        // The no-data filling algorithm does not like transparent images
        this.needsUpdate = this.transparent !== options.transparent;
        this.transparent = options.transparent ?? false;
        this.opacity = 1;
        this.uniforms.opacity.value = this.opacity;
        this.uniforms.interpretation.value = interpValue;
        this.uniforms.tex.value = options.texture;
        this.uniforms.flipY.value = options.flipY ?? false;
        this.uniforms.noDataOptions.value = options.noDataOptions ?? {
            enabled: false,
            radius: 0,
            replacementAlpha: 0,
        };
        this.uniforms.showImageOutlines.value = options.showImageOutlines ?? false;
        this.uniforms.expandRGB.value = options.expandRGB ?? false;
        this.uniforms.showEmptyTexture.value = options.showEmptyTexture ?? false;
        this.uniforms.isEmptyTexture.value = TextureGenerator.isEmptyTexture(options.texture);
        this.uniforms.convertRGFloatToRGBAUnsignedByte.value =
            options.convertRGFloatToRGBAUnsignedByte != null;
        this.uniforms.heightPrecision.value =
            options.convertRGFloatToRGBAUnsignedByte?.precision ?? 0.1;
        this.uniforms.heightOffset.value = options.convertRGFloatToRGBAUnsignedByte?.offset ?? 0.1;

        const channelCount = TextureGenerator.getChannelCount(this.pixelFormat);
        this.uniforms.channelCount.value = channelCount;
        if (options.showImageOutlines) {
            if (GRID_TEXTURE == null) {
                GRID_TEXTURE = createGridTexture();
            }
            this.uniforms.gridTexture.value = GRID_TEXTURE;
        }
    }

    private reset(): void {
        this.uniforms.tex.value = null;
    }

    /**
     * Acquires a pooled material.
     *
     * @param opts - The options.
     */
    public static acquire(opts: Options): ComposerTileMaterial {
        if (POOL.length > 0) {
            const mat = POOL.pop() as ComposerTileMaterial;
            mat.init(opts);
            return mat;
        }
        return new ComposerTileMaterial(opts);
    }

    /**
     * Releases the material back into the pool.
     *
     * @param material - The material.
     */
    public static release(material: ComposerTileMaterial): void {
        material.reset();
        if (POOL.length < POOL_SIZE) {
            POOL.push(material);
        } else {
            material.dispose();
        }
    }
}

export function isComposerTileMaterial(obj: unknown): obj is ComposerTileMaterial {
    return (obj as ComposerTileMaterial)?.isComposerTileMaterial;
}

export default ComposerTileMaterial;
