/**
 * VoxelRenderer — three.js как низкоуровневый рендер-бэкенд (решение v2).
 *
 * Граница рендера: мир живёт в воксельных единицах, проекция происходит
 * только здесь (урок v1 №1). Низкое разрешение бэкинг-стора + CSS-целый
 * масштаб с image-rendering: pixelated даёт пиксель-арт взгляд (продолжение
 * стиля v1). AO запечён в вершинные цвета мешером; свет — dirLight с
 * shadow map + мягкий ambient, чтобы тени не проваливались в чёрное.
 */
import * as THREE from 'three';
import type { VoxelMesh } from '../voxel/mesher';
import type { PosedVoxel } from '../animation/rig';
import type { ResolvedTile } from '../models/format';
import { cloudDim, lerpColor } from './daynight';
import { createSpriteLayer, createQuadLayer, makeParticleLightUniforms, packParticleLights, particleEnv } from './particles';
import type { ParticleLayer, ParticleLight, ParticleSpot, ParticleLightUniforms } from './particles';
import { createFogComposite, makeFogUniforms, fogColor } from './fog';
import { LIGHTNING_FLASH, LIGHTNING_TOP } from './lightning';
import { particleWindow } from './polyWindow';

/** Облако деформированных вокселей (позы рига — не выровнены по сетке). */
export interface VoxelCloud {
    /** Заменить воксели облака (позиции, цвета, тайли, rotY — количество). */
    update(voxels: readonly Pick<PosedVoxel, 'pos' | 'color' | 'tile' | 'rotY'>[]): void;
    dispose(): void;
    mesh: THREE.InstancedMesh;
}

/** Текстуры граней облака: id тайля → разрешённые hex-пиксели (см. resolveTiles). */
export type CloudTiles = Record<number, ResolvedTile>;

/** Параметры точечного источника света (окна, очаг, лампа героя). */
export interface PointLightOptions {
    /** Позиция в мировых единицах. */
    pos: [number, number, number];
    color: number;
    /** Яркость 0..~1.5 (множитель движка). */
    intensity: number;
    /** Радиус действия в мировых единицах (дальше — гаснет). */
    radius: number;
}

/** Ручка точечного источника: игра меняет яркость (мерцание) сама. */
export interface PointLightHandle {
    setIntensity(intensity: number): void;
    setPos(pos: [number, number, number]): void;
    dispose(): void;
}

/** Параметры прожектора (фонарик героя): конус света вперёд. */
export interface SpotLightOptions {
    /** Позиция в мировых единицах. */
    pos: [number, number, number];
    color: number;
    /** Яркость 0..~1.5 (множитель движка, усиливается POINT_LIGHT_GAIN). */
    intensity: number;
    /** Дальность луча в мировых единицах. */
    distance: number;
    /** Полный угол конуса (рад). */
    angle: number;
    /** Мягкость края конуса 0..1 (по умолчанию 0.4). */
    penumbra?: number;
}

/** Ручка прожектора: игра двигает источник/цель и меняет яркость сама. */
export interface SpotLightHandle {
    setIntensity(intensity: number): void;
    setPos(pos: [number, number, number]): void;
    /** Направить луч в мировую точку (цель — позиция + направление). */
    setTarget(p: [number, number, number]): void;
    dispose(): void;
}

/** Усиление интенсивности точечных источников: three r155+ считает PointLight
 *  в физических единицах (I/dist^decay) — при decay 1.6 на дистанции 5 вокселей
 *  от игрового множителя ~0.9 остаётся ~0.07 (свет невиден). GAIN применяет
 *  addPointLight и setIntensity (мерцание приходит «сырым» множителем игры). */
const POINT_LIGHT_GAIN = 8;

export interface VoxelRendererOptions {
    /** низкое разрешение рендера (пиксель-арт), по умолчанию 480×270 */
    width?: number;
    height?: number;
    /** центр ортокамеры в мировых единицах */
    target?: [number, number, number];
    /** половина высоты орто-фрустума (зум), в мировых единицах */
    viewSize?: number;
}

/** Изометрия демо: диметрия 2:1 — yaw 45°, тангенс угла возвышения 1/2. */
export const ISO_YAW = Math.PI / 4;
const ISO_TAN = 0.5;
/** Максимальная высота кастера тени (высота чанка мира) — для радиуса
 *  теневого фрустума: тень кастера уходит от него на h/tan(elev солнца). */
const SHADOW_CASTER_H = 64;

/** Мировой радиус размытия тени при мягкости 1 (0.3 тайла): у полной
 *  облачности свет рассеяный — тень readability сохраняет, край плывёт. */
export const SHADOW_BLUR_MAX = 3;

/** Радиус размытия тени в текселях из мягкости 0..1: мировой радиус
 *  (0..SHADOW_BLUR_MAX) делится на тексель текущего теневого фрустума —
 *  размытие в МИРОВЫХ единицах не зависит от размера фрустума (зум, высота
 *  солнца). Минимум 1 тексель — резкая тень (радиус < 1 бесполезен: PCF
 *  сэмплирует диском радиуса shadowRadius текселей). */
export function shadowBlurTexels(softness: number, frustumHalf: number, mapSize: number): number {
    const k = Math.min(1, Math.max(0, softness));
    const texel = (2 * frustumHalf) / mapSize;
    if (!(texel > 0)) return 1;
    return Math.max(1, Math.round((k * SHADOW_BLUR_MAX) / texel));
}

/** Насколько полная облачность гасит контраст теней (0..1): рассеяный свет
 *  освещает затенённые места со всех сторон, тени светлеют. 0.45 — тень в
 *  пасмурный день видна, но заметно мягче по яркости, чем в ясный. */
export const SHADOW_CLOUD_FADE = 0.45;

/** Сила (плотность) тени 0..1 из силы теней и облачности: sun.shadow.intensity
 *  в шейдере — mix(1.0, shadow, intensity), т.е. 0.5 = тень светлее вдвое.
 *  Ручная сила уменьшается облачностью (рассеяный свет заполняет тени). */
export function shadowIntensity(strength: number, cloudK: number): number {
    const s = Math.min(1, Math.max(0, strength));
    const c = Math.min(1, Math.max(0, cloudK));
    return Math.max(0, s * (1 - c * SHADOW_CLOUD_FADE));
}

/** Яркость зенитного света полной облачности днём: рассеяный свет
 *  переотражается так, словно слабый источник висит прямо над сценой —
 *  минимальная тень лежит ПОД объектом, куда косые лучи солнца не достают.
 *  Ночь и тёмный интерьер гасят его вместе с небом. 0.55 — контактная тень
 *  читается в изометрии (прямая вниз тень почти целиком скрыта объектом,
 *  виден только размытый ореол у основания — он и нужен). */
export const OVERCAST_TOP_LIGHT = 0.55;

/** Интенсивность зенитного света из облачности/ночи/яркости интерьера:
 *  0 в ясный день (тени ведёт солнце), максимум в пасмурный полдень. */
export function zenithIntensity(cloudK: number, nightK: number, daySunScale: number): number {
    const c = Math.min(1, Math.max(0, cloudK));
    const n = Math.min(1, Math.max(0, nightK));
    const s = Math.min(1, Math.max(0, daySunScale));
    return OVERCAST_TOP_LIGHT * c * (1 - 0.92 * n) * s;
}

/** Ручка пост-прохода: юниформы (uTime и пр. — игра анимирует сама) и отключение. */
export interface PostPassHandle {
    readonly uniforms: Record<string, THREE.IUniform>;
    dispose(): void;
}

/** Вершишник полноэкранного квада пост-прохода (PlaneGeometry даёт uv). */
const POST_VERT = `
varying vec2 vUv;
void main() {
    vUv = uv;
    gl_Position = vec4(position.xy, 0.0, 1.0);
}`;

export class VoxelRenderer {
    readonly renderer: THREE.WebGLRenderer;
    readonly scene: THREE.Scene;
    readonly camera: THREE.OrthographicCamera;
    private readonly sun: THREE.DirectionalLight;
    /** Зенитный свет пасмурности (вниз, с тенью): минимальная контактная
     *  тень прямо под объектами — рассеяный свет переотражается сверху. */
    private readonly zenith: THREE.DirectionalLight;
    /** Вспышка молнии (свет из точки разряда, с тенью) — см. setLightning. */
    private readonly flash: THREE.DirectionalLight;
    private flashK = 0; // текущая огибающая вспышки (0 — погашена)
    private flashAt: [number, number, number] | null = null;
    private readonly ambient: THREE.HemisphereLight;
    private readonly target: THREE.Vector3;
    private readonly viewSize: number;
    /** Аспект кадра: полуширина видимой области = viewSize·max(aspect,1). */
    private readonly viewAspect: number;
    /** Общий блок юниформ света частиц: все слои ссылаются на те же IUniform —
     *  рост ёмкости слоя и пересборка области свет не теряют. */
    private readonly particleLightsU: ParticleLightUniforms = makeParticleLightUniforms();
    /** Цвет тумана (общий блок, шарится с композитным квадом тумана). */
    private readonly fogU: Record<string, THREE.IUniform> = makeFogUniforms();
    /** Туман: сила/время/ветер (setFog) и лениво собранный композитный проход. */
    private fogStrength = 0;
    private fogTime = 0;
    private fogWind: readonly [number, number] = [0, 0];
    private fogPass: ReturnType<typeof createFogComposite> | null = null;
    private sunK = 0.5;
    private nightK = 0;
    /** Облачность 0..1: гасит солнце и ambient, фон — к пасмурному серому.
     *  По умолчанию тянет за собой мягкость теней (рассеяный свет). */
    private cloudK = 0;
    /** Мягкость теней 0..1 (0 — резкие): радиус размытия shadow map. */
    private shadowSoftK = 0;
    /** Сила (контрастность) теней 0..1: 1 — полные тени, 0 — исчезают.
     *  Рассеяный свет заполняет тени — облачность ослабляет по умолчанию. */
    private shadowStrengthK = 1;
    /** Полуширина текущего теневого фрустума (ставит setSun) — для пересчёта
     *  радиуса размытия в текселях при смене мягкости/фрустума. */
    private shadowFrustumHalf = 0;
    /** Последний ночной tint setNight — чтобы setCloud пересчитывал сразу. */
    private lastNightTint = 0x94949e;
    /** Дневной базовый ambient области (интерьеры темнее улиц — как v1 setAmbient). */
    private dayHemisphere = 0x94949e; // P5
    /** Дневной множитель солнца (тёмный интерьер гасит и прямое солнце). */
    private daySunScale = 1;
    /** Активный пост-проход: RT со сценой + полноэкранный квад (null — прямой рендер). */
    private post: {
        rt: THREE.WebGLRenderTarget;
        scene: THREE.Scene;
        camera: THREE.OrthographicCamera;
        quad: THREE.Mesh;
        mat: THREE.ShaderMaterial;
    } | null = null;

    constructor(canvas: HTMLCanvasElement, opts: VoxelRendererOptions = {}) {
        const width = opts.width ?? 480;
        const height = opts.height ?? 270;
        this.target = new THREE.Vector3(...(opts.target ?? [0, 0, 0]));
        this.viewSize = opts.viewSize ?? 16;
        this.viewAspect = width / height;

        this.renderer = new THREE.WebGLRenderer({ canvas, antialias: false });
        this.renderer.setSize(width, height, false); // CSS-размер задаёт страница
        this.renderer.shadowMap.enabled = true;
        this.renderer.shadowMap.type = THREE.PCFShadowMap;

        this.scene = new THREE.Scene();

        this.camera = this.makeCamera(width, height);

        this.ambient = new THREE.HemisphereLight(0x94949e, 0x343b2c, 0.55); // P5 / G0
        this.scene.add(this.ambient);

        this.sun = new THREE.DirectionalLight(0xffffff, 1.4);
        this.sun.castShadow = true;
        this.sun.shadow.mapSize.set(2048, 2048); // фрустум широкий — плотность теней та же
        // левый/правый/верх/низ фрустума и far ставит setSun — радиус зависит
        // от аспекта кадра и высоты солнца (статичный 1.2·viewSize был меньше
        // видимой области: граница включения теней была видна как резкая линия)
        this.sun.shadow.bias = -0.0005;
        this.scene.add(this.sun);
        this.scene.add(this.sun.target);
        this.zenith = new THREE.DirectionalLight(0xffffff, 0);
        this.zenith.castShadow = true;
        this.zenith.shadow.mapSize.set(1024, 1024); // слабый свет — плотность не критична
        this.zenith.shadow.bias = -0.0005;
        this.scene.add(this.zenith);
        this.scene.add(this.zenith.target);
        // вспышка молнии: направленный свет с тенями из точки разряда,
        // по умолчанию погашен (visible false — shadow map не рендерится)
        this.flash = new THREE.DirectionalLight(0xdfe8ff, 0);
        this.flash.castShadow = true;
        this.flash.visible = false;
        this.flash.shadow.mapSize.set(1024, 1024);
        this.flash.shadow.bias = -0.0005;
        this.scene.add(this.flash);
        this.scene.add(this.flash.target);
        this.setSun(0.5);
        this.repositionCamera();
    }

    /** Перенести цель камеры (и солнце за ней) — слежение камеры за героем. */
    setTarget(t: readonly [number, number, number]): void {
        this.target.set(t[0], t[1], t[2]);
        this.repositionCamera();
        this.setSun(this.sunK);
    }

    /** Текущая цель камеры (для моста/гейта). */
    currentTarget(): [number, number, number] {
        return [this.target.x, this.target.y, this.target.z];
    }

    /**
     * Дневной базовый тон области: цвет неба hemisphere днём (улицы — P5,
     * тёмные интерьеры — их ambient) + дневной множитель солнца по яркости
     * (аналог multiply-ambient v1: тёмная лавка гасит и прямое солнце).
     */
    setDayAmbient(color: number): void {
        this.dayHemisphere = color & 0xffffff;
        const r = (color >> 16) & 0xff;
        const g = (color >> 8) & 0xff;
        const b = color & 0xff;
        const lum = (r * 0.3 + g * 0.6 + b * 0.1) / 255;
        this.daySunScale = Math.min(1, Math.max(0, lum * 1.6));
    }

    /**
     * Ночь: k — фактор ночи 0..1 (кривая суток игры), tint — целевой
     * ambient-цвет области (микс день/закат/ночь, кривая dayNightAmbient).
     * Гасит солнце и сдвигает дневное небо hemisphere к tint; фон сцены — tint.
     */
    setNight(k: number, tint: number): void {
        this.nightK = Math.min(1, Math.max(0, k));
        this.lastNightTint = tint & 0xffffff;
        this.applyCloud(); // ambient/фон с учётом облачности
        this.setSun(this.sunK); // солнце пересчитает себя с учётом nightK
        if (!this.scene.background) this.scene.background = new THREE.Color();
    }

    /** Облачность k 0..1: солнце гаснет (~×0.4 в полную), ambient темнее
     *  и серее, фон — к пасмурному серому. Свет становится рассеяным —
     *  тени мягчеют (мягкость = облачность). Вызывается и сама (панель/мост),
     *  и внутри setNight (тик игры перезаписывает ambient каждый тик). */
    setCloud(k: number): void {
        this.cloudK = Math.min(1, Math.max(0, k));
        this.shadowSoftK = this.cloudK;
        this.applyCloud();
        this.setSun(this.sunK);
    }

    /** Сила (контрастность) теней 0..1: 1 — полные тени, 0 — тени исчезают
     *  (рассеяный свет со всех сторон). Облачность не перезаписывает силу
     *  (в отличие от мягкости), а гасит её множителем — тень в пасмурный день
     *  светлеет сама: shadowIntensity(k, cloudK). */
    setShadowStrength(k: number): void {
        this.shadowStrengthK = Math.min(1, Math.max(0, k));
        this.sun.shadow.intensity = shadowIntensity(this.shadowStrengthK, this.cloudK);
    }

    /** Мягкость теней 0..1: 0 — резкие, 1 — размытие ~SHADOW_BLUR_MAX
     *  мировых юнитов (радиус shadow map в текселях считается от текущего
     *  фрустума). Ручное управление (например, свой стиль теней области);
     *  setCloud перезаписывает мягкость своим k — ручное значение задаём
     *  после него / на каждом тике. */
    setShadowSoftness(k: number): void {
        this.shadowSoftK = Math.min(1, Math.max(0, k));
        if (this.shadowFrustumHalf > 0) {
            this.sun.shadow.radius = shadowBlurTexels(
                this.shadowSoftK, this.shadowFrustumHalf, this.sun.shadow.mapSize.x,
            );
        }
    }

    /** Ambient и фон: базовый микс день/ночь, затем облачность — к серому
     *  (обесцвечивание) и вниз по яркости. */
    private applyCloud(): void {
        const base = lerpColor(this.dayHemisphere, this.lastNightTint, this.nightK);
        this.ambient.color.set(cloudDim(base, this.cloudK, 0.7, 0.25));
        if (!this.scene.background) this.scene.background = new THREE.Color();
        (this.scene.background as THREE.Color).set(cloudDim(this.lastNightTint, this.cloudK, 0.7, 0.3));
    }

    /** Точечный источник (не бросает тень): мерцание — через setIntensity.
     *  Интенсивность spec'а (~0..1.5) — «множитель игры», усиливается
     *  POINT_LIGHT_GAIN (физические единицы three — см. константу). */
    addPointLight(opts: PointLightOptions): PointLightHandle {
        const light = new THREE.PointLight(opts.color, opts.intensity * POINT_LIGHT_GAIN, opts.radius * 2, 1.6);
        light.position.set(...opts.pos);
        this.scene.add(light);
        return {
            setIntensity: (i) => {
                light.intensity = i * POINT_LIGHT_GAIN;
            },
            setPos: (p) => {
                light.position.set(p[0], p[1], p[2]);
            },
            dispose: () => {
                this.scene.remove(light);
                light.dispose();
            },
        };
    }

    /** Прожектор (не бросает тень): конус света — фонарик героя. Яркость
     *  усиливается POINT_LIGHT_GAIN (как у точечных, см. константу). Цель
     *  луча — свой объект сцены: setTarget двигает её, свет поворачивается. */
    addSpotLight(opts: SpotLightOptions): SpotLightHandle {
        const light = new THREE.SpotLight(
            opts.color, opts.intensity * POINT_LIGHT_GAIN, opts.distance,
            opts.angle, opts.penumbra ?? 0.4, 1.0,
        );
        light.position.set(...opts.pos);
        this.scene.add(light);
        this.scene.add(light.target);
        return {
            setIntensity: (i) => {
                light.intensity = i * POINT_LIGHT_GAIN;
            },
            setPos: (p) => {
                light.position.set(p[0], p[1], p[2]);
            },
            setTarget: (p) => {
                light.target.position.set(p[0], p[1], p[2]);
            },
            dispose: () => {
                this.scene.remove(light);
                this.scene.remove(light.target);
                light.dispose();
            },
        };
    }

    /** Материал вокселей: вершинные цвета (палитра × AO) + ламберт от солнца. */
    static material(): THREE.MeshLambertMaterial {
        return new THREE.MeshLambertMaterial({ vertexColors: true });
    }

    /** Добавляет меш из MeshData мешера в сцену (тени: cast + receive). */
    addMesh(mesh: VoxelMesh): THREE.Mesh {
        const geo = new THREE.BufferGeometry();
        geo.setAttribute('position', new THREE.Float32BufferAttribute(mesh.positions, 3));
        geo.setAttribute('normal', new THREE.Float32BufferAttribute(mesh.normals, 3));
        geo.setAttribute('color', new THREE.Float32BufferAttribute(mesh.colors, 3));
        geo.setIndex(mesh.indices);
        const meshObj = new THREE.Mesh(geo, VoxelRenderer.material());
        meshObj.castShadow = true;
        meshObj.receiveShadow = true;
        this.scene.add(meshObj);
        return meshObj;
    }

    /**
     * Облако вокселей для поз анимации: instanced кубы 1×1×1 (ось-выровнены),
     * позиция и цвет — на инстанс. Обновление позы — без пересборки геометрии.
     * tiles — текстуры граней (см. resolveTiles): воксель с tile>0 рисуется
     * пиксель-артом тайля (цвет инстанса — белый), без tile — плоским цветом.
     */
    addVoxelCloud(voxels: readonly Pick<PosedVoxel, 'pos' | 'color' | 'tile' | 'rotY'>[], tiles?: CloudTiles): VoxelCloud {
        return createCloud(this.scene, voxels, tiles);
    }

    /**
     * Слой частиц-спрайтов (билборды, лицом к камере): пепел, мотыльки.
     * Размер частицы — в мировых юнитах, переводится в пиксели буфера один
     * раз (канвас рендера не меняется после создания).
     */
    addParticles(opts: { lit?: boolean } = {}): ParticleLayer {
        const px = this.renderer.domElement.height / (2 * this.viewSize);
        return createSpriteLayer(this.scene, 64, px, this.particleLightsU, opts.lit ?? false);
    }

    /** Слой плоских квадов, лежащих на земле/воде: дымка прудов, блики. */
    addFlatParticles(opts: { lit?: boolean } = {}): ParticleLayer {
        return createQuadLayer(this.scene, 64, this.particleLightsU, opts.lit ?? false);
    }

    /**
     * Туман погоды (render/fog.ts): сила 0..1 + время цикла + вектор ветра.
     * Зовётся системой погоды каждый тик; рендер композитит слои по глубине
     * в render() (сцена → RT с depth-текстурой → полноэкранный квад).
     */
    setFog(strength: number, t: number, wind: readonly [number, number]): void {
        this.fogStrength = Math.min(1, Math.max(0, strength));
        this.fogTime = t;
        this.fogWind = wind;
    }

    /** Снапшот состояния тумана (диагностика гейтов): сила, собран ли
     *  композитный проход, цвет и юниформы композита (пространственные —
     *  сверка реконструкции с камерой). */
    fogInfo(): {
        strength: number; composite: boolean;
        color: [number, number, number];
        cam: { pos: [number, number, number]; near: number; far: number; halfW: number; halfH: number } | null;
    } {
        const u = this.fogPass?.mat.uniforms;
        const c = this.fogU.uFogColor!.value as THREE.Vector3;
        const p = u?.uCamPos!.value as THREE.Vector3 | undefined;
        return {
            strength: this.fogStrength,
            composite: this.fogPass !== null,
            color: [c.x, c.y, c.z],
            cam: u && p ? {
                pos: [p.x, p.y, p.z],
                near: u.uNear!.value as number,
                far: u.uFar!.value as number,
                halfW: u.uHalfW!.value as number,
                halfH: u.uHalfH!.value as number,
            } : null,
        };
    }

    /**
     * Свет частицам: снапшот источников игры (общий для всех слоёв рендера —
     * вклад считается в шейдере частиц, ambient-ночь идёт отдельно через
     * setTint слоёв). Вызывать каждый тик — мерцание источников едет с миром.
     */
    setParticleLights(points: readonly ParticleLight[], spot: ParticleSpot | null = null): void {
        packParticleLights(this.particleLightsU, points, spot);
    }

    /**
     * Окно видимости частиц: AABB орто-фрустума, пересечённого с горизон-
     * тальным слоем [y0,y1], вокруг цели камеры. Точка вне окна заведомо не
     * проецируется на экран — там частицы заворачивают/респавнятся без
     * «появления из воздуха». Окно заметно шире наземного следа кадра:
     * высокую частицу видно дальше от цели, а сляб уходит в глубину до
     * дальней плоскости (точно — для частиц много ниже камеры, как все
     * игровые слои).
     */
    visibleWindow(y0: number, y1: number): { minX: number; maxX: number; minZ: number; maxZ: number } {
        // БЕЗ кэша: окно обязано ехать за камерой (грабля — кэш от первого
        // запроса замирал на окне вокруг origin, частицы получали alpha 0)
        this.camera.updateMatrixWorld();
        const e = this.camera.matrixWorld.elements;
        // базис камеры: r — экранная горизонталь, u — вертикаль, f — взгляд
        const r = [e[0]!, e[1]!, e[2]!] as const;
        const u = [e[4]!, e[5]!, e[6]!] as const;
        const f = [-e[8]!, -e[9]!, -e[10]!] as const;
        const c = this.camera.position;
        const t = this.target;
        // глубина цели вдоль взгляда: ближняя/дальняя плоскости относительно неё
        const D = f[0] * (t.x - c.x) + f[1] * (t.y - c.y) + f[2] * (t.z - c.z);
        const box = particleWindow({
            r, u, f,
            a: this.camera.right, h: this.camera.top,
            k0: this.camera.near - D, k1: this.camera.far - D,
            dy0: y0 - t.y, dy1: y1 - t.y,
        });
        // AABB посчитан в дельтах от цели камеры — переводим в мировые координаты
        return {
            minX: box.minX + t.x, maxX: box.maxX + t.x,
            minZ: box.minZ + t.z, maxZ: box.maxZ + t.z,
        };
    }

    /**
     * Солнце дня: k — 0 рассвет → 0.5 полдень → 1 закат. Азимут едет по
     * мировой горизонтали (восток→запад), высота — по синусу дня; цвет теплеет
     * у горизонта. Тени на земле ползут от k — это ловит скриншот-гейт.
     */
    setSun(k: number): void {
        this.sunK = k;
        const kk = Math.min(1, Math.max(0, k));
        const az = Math.PI * (1 - kk); // 180°→0°: восток → юг → запад
        const elev = Math.sin(kk * Math.PI) * (Math.PI / 3) + 0.12; // не ниже ~7°
        const dist = this.viewSize * 3;
        this.sun.position.set(
            this.target.x + dist * Math.cos(az) * Math.cos(elev),
            this.target.y + dist * Math.sin(elev),
            this.target.z + dist * Math.sin(az) * Math.cos(elev),
        );
        this.sun.target.position.copy(this.target);
        // Теневой фрустум накрывает всю видимую область (полуширина кадра
        // viewSize·max(aspect,1) — иначе граница включения теней видна как
        // резкая линия на экране) плюс длину тени самого высокого кастера
        // (чанк 64) при текущей высоте солнца: тень уходит от кастера на
        // h/tan(elev). far — от позиции солнца (dist) до дальнего края.
        const visible = this.viewSize * Math.max(this.viewAspect, 1);
        const r = Math.ceil(visible + SHADOW_CASTER_H / Math.tan(elev));
        const cam = this.sun.shadow.camera;
        if (cam.right !== r) {
            cam.left = -r;
            cam.right = r;
            cam.top = r;
            cam.bottom = -r;
            cam.near = 0.5;
            cam.far = dist + r + SHADOW_CASTER_H + this.viewSize;
            cam.updateProjectionMatrix();
        }
        // фрустум меняется с высотой солнца — радиус размытия в текселях
        // пересчитывается, чтобы МИРОВОЙ размер размытия не менялся
        this.shadowFrustumHalf = r;
        this.sun.shadow.radius = shadowBlurTexels(this.shadowSoftK, r, this.sun.shadow.mapSize.x);
        // контраст теней пересчитывается здесь (setSun зовётся каждым тиком
        // света и из setCloud) — ручное значение переживает обновления солнца
        this.sun.shadow.intensity = shadowIntensity(this.shadowStrengthK, this.cloudK);
        // ночь гасит солнце до остаточной засветки (луна/небо), тёмный
        // интерьер — и днём, облачность — в пасмурный остаток
        this.sun.intensity = (0.4 + 1.1 * Math.sin(kk * Math.PI)) * (1 - 0.92 * this.nightK)
            * this.daySunScale * (1 - 0.6 * this.cloudK);
        // тёплый свет у горизонта (F1), нейтральный в полдень
        const warm = 1 - Math.sin(kk * Math.PI);
        this.sun.color.setRGB(1, 1 - warm * 0.25, 1 - warm * 0.45);
        // «тенька сверху» пасмурности: зенитный свет с тенью — контактная
        // тень прямо под объектом (косые солнечные лучи туда не достают).
        // Мягкость общая с солнцем: рассеяный свет и здесь размывает край.
        this.zenith.position.set(
            this.target.x,
            this.target.y + SHADOW_CASTER_H + this.viewSize,
            this.target.z,
        );
        this.zenith.target.position.copy(this.target);
        this.zenith.intensity = zenithIntensity(this.cloudK, this.nightK, this.daySunScale);
        // зенитный кастер не смещает тень вбок — фрустум только на видимую
        // область; far — от света сверху до земли с запасом
        const top = Math.ceil(visible) + 2;
        const zc = this.zenith.shadow.camera;
        if (zc.right !== top) {
            zc.left = -top;
            zc.right = top;
            zc.top = top;
            zc.bottom = -top;
            zc.near = 0.5;
            zc.far = 2 * (SHADOW_CASTER_H + this.viewSize);
            zc.updateProjectionMatrix();
        }
        this.zenith.shadow.radius = shadowBlurTexels(this.shadowSoftK, top, this.zenith.shadow.mapSize.x);
        // env-свет освещаемых частиц: ambient + доля солнца — ночь темнит
        // их без ручных tint'ов (setSun зовётся при любом изменении света)
        const e = particleEnv(
            { r: this.ambient.color.r, g: this.ambient.color.g, b: this.ambient.color.b, intensity: this.ambient.intensity },
            { r: this.sun.color.r, g: this.sun.color.g, b: this.sun.color.b, intensity: this.sun.intensity },
        );
        (this.particleLightsU.uEnv!.value as THREE.Vector3).set(e[0], e[1], e[2]);
        // цвет тумана: тот же env-свет с яркой долей солнца — туман освещается
        // вместе с миром (setSun зовётся при любом изменении света)
        const fc = fogColor(
            { r: this.ambient.color.r, g: this.ambient.color.g, b: this.ambient.color.b, intensity: this.ambient.intensity },
            { r: this.sun.color.r, g: this.sun.color.g, b: this.sun.color.b, intensity: this.sun.intensity },
        );
        (this.fogU.uFogColor!.value as THREE.Vector3).set(fc[0], fc[1], fc[2]);
    }

    /**
     * Вспышка молнии: направленный свет из точки разряда (at — точка удара
     * о землю, рендер/lightning.ts), k — огибающая мерцания 0..1. Свет бьёт
     * из разряда вниз с лёгким наклоном — строго вертикальный свет прячет
     * тени под самим объектом. Фрустум — как у зенитного света (вся видимая
     * область). null или k ≤ 0 — погасить (visible false — shadow map не
     * рендерится). Ночь усиливает вспышку: днём она тонет в солнечном свете.
     */
    setLightning(at: readonly [number, number, number] | null, k: number): void {
        this.flashK = k;
        this.flashAt = at ? [at[0], at[1], at[2]] : null;
        if (!at || k <= 0) {
            this.flash.visible = false;
            this.flash.intensity = 0;
            return;
        }
        this.flash.position.set(
            at[0] + LIGHTNING_TOP * 0.25,
            at[1] + LIGHTNING_TOP,
            at[2] - LIGHTNING_TOP * 0.18,
        );
        this.flash.target.position.set(at[0], at[1], at[2]);
        this.flash.intensity = k * LIGHTNING_FLASH * (1 + this.nightK);
        const visible = this.viewSize * Math.max(this.viewAspect, 1);
        const r = Math.ceil(visible) + 2;
        const c = this.flash.shadow.camera;
        if (c.right !== r) {
            c.left = -r;
            c.right = r;
            c.top = r;
            c.bottom = -r;
            c.near = 0.5;
            // от света (высота LIGHTNING_TOP над землёй) до земли и дальше —
            // наклон луча и самые высокие кастеры с запасом
            c.far = LIGHTNING_TOP * 2 + SHADOW_CASTER_H + this.viewSize;
            c.updateProjectionMatrix();
        }
        this.flash.shadow.radius = shadowBlurTexels(this.shadowSoftK, r, this.flash.shadow.mapSize.x);
        this.flash.visible = true;
    }

    /** Снапшот вспышки молнии (диагностика гейтов). */
    lightningInfo(): { flash: number; at: [number, number, number] | null } {
        return { flash: this.flashK, at: this.flashAt ? [...this.flashAt] : null };
    }

    /** Точка фокуса камеры (цель) — опорная точка мира для систем погоды. */
    focusPoint(): [number, number, number] {
        const t = this.target;
        return [t.x, t.y, t.z];
    }

    /**
     * Пост-обработка: сцена рендерится в render target, затем на экран
     * полноэкранным квадом с шейдером игры (виньетка, дисторсия...).
     * null — вернуться к прямому рендеру. Юниформы прохода игрок меняет
     * через ручку (uTime и пр.). Ресурсов мало, но dispose обязателен.
     */
    setPostPass(
        fragmentShader: string | null,
        uniforms: Record<string, THREE.IUniform> = {},
    ): PostPassHandle | null {
        this.clearPostPass();
        if (fragmentShader === null) return null;
        const size = new THREE.Vector2();
        this.renderer.getSize(size);
        const rt = new THREE.WebGLRenderTarget(size.x, size.y, {
            minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter, // пиксель-арт: 1:1
        });
        const mat = new THREE.ShaderMaterial({
            vertexShader: POST_VERT,
            fragmentShader,
            // uTime заводится всегда (пост-шейдер собирается с его объявлением),
            // эффектные юниформы — из spec'а игры
            uniforms: { tDiffuse: { value: rt.texture }, uTime: { value: 0 }, ...uniforms },
            depthTest: false,
            depthWrite: false,
        });
        const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), mat);
        quad.frustumCulled = false;
        const scene = new THREE.Scene();
        scene.add(quad);
        const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
        this.post = { rt, scene, camera, quad, mat };
        return {
            uniforms: mat.uniforms,
            dispose: () => this.clearPostPass(),
        };
    }

    /** Снять активный пост-проход и освободить его ресурсы. */
    private clearPostPass(): void {
        const p = this.post;
        if (!p) return;
        this.post = null;
        p.scene.remove(p.quad);
        p.quad.geometry.dispose();
        p.mat.dispose();
        p.rt.dispose();
    }

    /** Один кадр. При активном тумане сцена идёт через RT с глубиной, затем
     *  композитный квад красит её слоями по дальности (render/fog.ts) —
     *  в post.rt игры (пост-проход поверх тумана) или прямо на канвас. */
    render(): void {
        this.camera.updateMatrixWorld();
        if (this.fogStrength > 0.001) {
            if (!this.fogPass) {
                const size = new THREE.Vector2();
                this.renderer.getSize(size);
                this.fogPass = createFogComposite(this.fogU, { x: size.x, y: size.y });
            }
            this.renderer.setRenderTarget(this.fogPass.rt);
            this.renderer.render(this.scene, this.camera);
            this.updateFogUniforms();
            // квад на канвасе обязан конверсию sRGB делать сам (RT линейный)
            const srgb = this.post ? 0 : 1;
            if ((this.fogPass.mat.defines!.SRGB_OUT as number) !== srgb) {
                this.fogPass.mat.defines!.SRGB_OUT = srgb;
                this.fogPass.mat.needsUpdate = true;
            }
            this.renderer.setRenderTarget(this.post ? this.post.rt : null);
            this.renderer.render(this.fogPass.scene, this.fogPass.camera);
            if (this.post) {
                this.renderer.setRenderTarget(null);
                this.renderer.render(this.post.scene, this.post.camera);
            }
            return;
        }
        if (this.post) {
            this.renderer.setRenderTarget(this.post.rt);
            this.renderer.render(this.scene, this.camera);
            this.renderer.setRenderTarget(null);
            this.renderer.render(this.post.scene, this.post.camera);
        } else {
            this.renderer.render(this.scene, this.camera);
        }
    }

    /** Юниформы композита тумана: базис камеры (позиция/взгляд/оси кадра),
     *  линейный диапазон глубины и текущие сила/время/ветер. */
    private updateFogUniforms(): void {
        const u = this.fogPass!.mat.uniforms;
        const e = this.camera.matrixWorld.elements;
        (u.uCamPos!.value as THREE.Vector3).copy(this.camera.position);
        (u.uCamF!.value as THREE.Vector3).set(-e[8]!, -e[9]!, -e[10]!);
        (u.uCamR!.value as THREE.Vector3).set(e[0]!, e[1]!, e[2]!);
        (u.uCamU!.value as THREE.Vector3).set(e[4]!, e[5]!, e[6]!);
        u.uNear!.value = this.camera.near;
        u.uFar!.value = this.camera.far;
        u.uHalfW!.value = this.camera.right;
        u.uHalfH!.value = this.camera.top;
        u.uStrength!.value = this.fogStrength;
        u.uTime!.value = this.fogTime;
        (u.uWind!.value as THREE.Vector2).set(this.fogWind[0], this.fogWind[1]);
    }

    /** Ортокамера диметрии 2:1, смотрит в target с изо-направления. */
    private makeCamera(width: number, height: number): THREE.OrthographicCamera {
        const aspect = width / height;
        const half = this.viewSize;
        return new THREE.OrthographicCamera(
            -half * aspect, half * aspect, half, -half, 0.1, this.viewSize * 10,
        );
    }

    /** Поставить камеру в изо-направление от текущего target. */
    private repositionCamera(): void {
        const dir = new THREE.Vector3(
            Math.cos(ISO_YAW) * ISO_TAN,
            1, // камера НАД сценой (минус давал вид из-под земли: верхние грани без света)
            Math.sin(ISO_YAW) * ISO_TAN,
        ).normalize();
        this.camera.position.copy(this.target).addScaledVector(dir, this.viewSize * 4);
        this.camera.lookAt(this.target);
    }
}

/**
 * Атлас тайлей: одна строка, ячейка тайля = 6 суб-граней подряд
 * (+x,−x,+y,−y,+z,−z), ячейка 0 — белая (плоский цвет × instanceColor).
 * Канвас рисуется сверху-вниз, flipY=true у CanvasTexture ставит низ канваса
 * на v=0 — низ грани сэмплирует низ арта, текстура читается естественно.
 */
function buildTileAtlas(tiles: CloudTiles): { texture: THREE.CanvasTexture; texel: number; width: number } {
    let texel = 1, maxId = 0;
    for (const [id, t] of Object.entries(tiles)) {
        texel = Math.max(texel, t.size);
        maxId = Math.max(maxId, Number(id));
    }
    const width = (maxId + 1) * 6 * texel;
    const canvas = document.createElement('canvas');
    canvas.width = width;
    canvas.height = texel;
    const ctx = canvas.getContext('2d')!;
    ctx.fillStyle = '#ffffff';
    ctx.fillRect(0, 0, 6 * texel, texel);
    for (const [id, t] of Object.entries(tiles)) {
        const k = texel / t.size; // тайли мельче texel растягиваются в ячейку
        const hexToCss = (hex: string) => (hex.length === 4 ? '#' + [...hex.slice(1)].map((c) => c + c).join('') : hex);
        t.faces.forEach((pixels, fi) => {
            pixels.forEach((hex, px) => {
                ctx.fillStyle = hexToCss(hex);
                ctx.fillRect((Number(id) * 6 + fi) * texel + (px % t.size) * k, Math.floor(px / t.size) * k, k, k);
            });
        });
    }
    const texture = new THREE.CanvasTexture(canvas);
    texture.magFilter = THREE.NearestFilter;
    texture.minFilter = THREE.NearestFilter;
    texture.generateMipmaps = false;
    texture.colorSpace = THREE.SRGBColorSpace;
    return { texture, texel, width };
}

/** Шейдерная инъция: uv сэмплинга атласа по грани куба (индекс из нормали).
 *  Общая для облака (aTileId — инстансный атрибут) и world-меша (обычный
 *  атрибут из мешера + uv) — объявление атрибута одинаково. */
function texturizeAtlasMaterial(mat: THREE.MeshLambertMaterial, texel: number, width: number): void {
    mat.onBeforeCompile = (shader) => {
        shader.uniforms.uTexel = { value: texel };
        shader.uniforms.uAtlasWidth = { value: width };
        shader.vertexShader = shader.vertexShader
            .replace('#include <common>', '#include <common>\nattribute float aTileId;\nvarying float vTile;\nvarying float vFace;')
            .replace('#include <begin_vertex>', [
                '#include <begin_vertex>',
                'vTile = aTileId;',
                'vFace = normal.x > 0.5 ? 0.0 : normal.x < -0.5 ? 1.0 : normal.y > 0.5 ? 2.0 : normal.y < -0.5 ? 3.0 : normal.z > 0.5 ? 4.0 : 5.0;',
            ].join('\n'));
        shader.fragmentShader = shader.fragmentShader
            .replace('#include <common>', '#include <common>\nvarying float vTile;\nvarying float vFace;\nuniform float uTexel;\nuniform float uAtlasWidth;')
            .replace('#include <map_fragment>', [
                '#ifdef USE_MAP',
                'vec2 cell = vec2((vTile * 6.0 + vFace) + vMapUv.x, vMapUv.y) * uTexel;',
                'diffuseColor *= texture2D(map, cell / vec2(uAtlasWidth, uTexel));',
                '#endif',
            ].join('\n'));
    };
}

/**
 * Общий атлас тайлей мира: один texture на все чанки области (материалы
 * чанков сэмплируют его через applyTo; texture дисползится ТОЛЬКО через
 * dispose владельца — не из материала отдельного чанка).
 */
export interface WorldTileAtlas {
    readonly texture: THREE.CanvasTexture;
    /** Подключить атлас к материалу чанка (шейдерная инъция сэмплинга). */
    applyTo(mat: THREE.MeshLambertMaterial): void;
    dispose(): void;
}

/**
 * Атлас реестра тайлей мира: мешер даёт per-vertex tiles/uvs (атрибуты
 * aTileId и uv у BufferGeometry), сэмплинг — общий с облаком. Пустой
 * реестр — null (материалы чанков остаются плоско-цветными).
 */
export function worldTileAtlas(tiles: CloudTiles): WorldTileAtlas | null {
    if (!tiles || Object.keys(tiles).length === 0) return null;
    const atlas = buildTileAtlas(tiles);
    return {
        texture: atlas.texture,
        applyTo: (mat) => {
            mat.map = atlas.texture;
            texturizeAtlasMaterial(mat, atlas.texel, atlas.width);
        },
        dispose: () => atlas.texture.dispose(),
    };
}

/** Фабрика облака (отдельная функция — рекурсия при росте ёмкости). */
function createCloud(
    scene: THREE.Scene,
    voxels: readonly Pick<PosedVoxel, 'pos' | 'color' | 'tile' | 'rotY'>[],
    tiles?: CloudTiles,
): VoxelCloud {
    const textured = !!tiles && Object.keys(tiles).length > 0;
    const geo = new THREE.BoxGeometry(1, 1, 1);
    const mat = new THREE.MeshLambertMaterial();
    let atlas: { texture: THREE.CanvasTexture; texel: number; width: number } | null = null;
    if (textured) {
        atlas = buildTileAtlas(tiles!);
        mat.map = atlas.texture;
        texturizeAtlasMaterial(mat, atlas.texel, atlas.width);
    }
    const mesh = new THREE.InstancedMesh(geo, mat, Math.max(1, voxels.length));
    mesh.castShadow = true;
    mesh.receiveShadow = true;
    mesh.frustumCulled = false; // bbox у InstancedMesh не считается по инстансам
    const tileAttr = new THREE.InstancedBufferAttribute(new Float32Array(mesh.instanceMatrix.count), 1);
    tileAttr.setUsage(THREE.DynamicDrawUsage);
    geo.setAttribute('aTileId', tileAttr);
    scene.add(mesh);

    const cloud: VoxelCloud = {
        mesh,
        update: (list) => {
            if (list.length > mesh.instanceMatrix.count) {
                scene.remove(mesh);
                geo.dispose();
                mat.dispose();
                atlas?.texture.dispose();
                const fresh = createCloud(scene, list, tiles);
                cloud.update = fresh.update;
                cloud.dispose = fresh.dispose;
                cloud.mesh = fresh.mesh;
                return;
            }
            const m = new THREE.Matrix4();
            const c = new THREE.Color();
            list.forEach((v, i) => {
                // куб поворачивается вместе с моделью (rotY), иначе на
                // диагональном ходе оси-выровненные кубы «пилят» углами
                m.makeRotationY(v.rotY ?? 0);
                mesh.setMatrixAt(i, m.setPosition(v.pos[0], v.pos[1], v.pos[2]));
                mesh.setColorAt(i, c.set(v.tile ? '#ffffff' : v.color));
                tileAttr.array[i] = v.tile ?? 0;
            });
            mesh.count = list.length;
            mesh.instanceMatrix.needsUpdate = true;
            if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
            tileAttr.needsUpdate = true;
        },
        dispose: () => {
            scene.remove(mesh);
            geo.dispose();
            mat.dispose();
            atlas?.texture.dispose();
        },
    };
    cloud.update(voxels);
    return cloud;
}