/**
 * Copyright (c) 2025 xwra
 * SPDX-License-Identifier: AGPL-3.0-or-later
 */

import { useEffect, useRef } from "preact/hooks";

interface Particle {
	offsetX: number;
	offsetY: number;
	velocityX: number;
	velocityY: number;
}

interface Raindrop {
	x: number;
	y: number;
	length: number;
	speed: number;
	opacity: number;
	floorHeight: number;
}

interface Splash {
	x: number;
	y: number;
	age: number;
	maxAge: number;
	particles: Particle[];
}

interface Star {
	x: number;
	y: number;
	size: number;
	brightness: number;
	twinkleSpeed: number;
	twinkleOffset: number;
	age: number;
	maxAge: number;
	canVanish: boolean;
	color: { r: number; g: number; b: number };
	colorIndex: number;
}

const PIXEL_SIZE = 4;
const DROP_COUNT = 300;
const RAIN_COLOR = { r: 87, g: 97, b: 100 };
const BACKGROUND_COLOUR = "#16191C";
const STAR_COUNT = 150;
const RAIN_SPEED = 0.75;

const SPLASH_CHANCE = 0.3;
const FLOOR_HEIGHT_MIN = 0.55;
const FLOOR_HEIGHT_RANGE = 0.95;

const PARTICLE_OFFSET_RANGE = 4;
const PARTICLE_Y_RANGE = 2;
const PARTICLE_Y_MIN = 1;
const PARTICLE_VX_RANGE = 0.8;
const PARTICLE_VY_RANGE = 0.5;
const PARTICLE_VY_MIN = 0.3;
const PARTICLE_GRAVITY = 0.15;

const STAR_BRIGHTNESS_MIN = 0.6;
const STAR_BRIGHTNESS_RANGE = 0.4;
const STAR_LARGE_THRESHOLD = 0.7;
const STAR_SIZE_SMALL = 4;
const STAR_SIZE_LARGE = 8;
const STAR_TWINKLE_MIN = 0.002;
const STAR_TWINKLE_RANGE = 0.005;
const STAR_AGE_MIN = 60;
const STAR_AGE_RANGE = 120;
const STAR_TWINKLE_AMPLITUDE = 0.3;
const STAR_TWINKLE_BASE = 0.7;
const STAR_GLOW_THRESHOLD = 0.7;
const STAR_GLOW_ALPHA = 0.5;

const RAIN_COLORS: string[] = [];
const STAR_COLORS: string[] = [];
const ALPHA_STAR_COLORS: string[] = [];
const NUM_COLOR_VARIATIONS = 20;

for (let i = 0; i < NUM_COLOR_VARIATIONS; i++) {
	const brightness = 1 - (i / NUM_COLOR_VARIATIONS) * 0.5;
	RAIN_COLORS.push(
		`rgb(${Math.floor(RAIN_COLOR.r * brightness)},${
			Math.floor(
				RAIN_COLOR.g * brightness,
			)
		},${Math.floor(RAIN_COLOR.b * brightness)})`,
	);
}

for (let i = 0; i < NUM_COLOR_VARIATIONS; i++) {
	const brightness = STAR_BRIGHTNESS_MIN +
		(i / NUM_COLOR_VARIATIONS) * STAR_BRIGHTNESS_RANGE;
	STAR_COLORS.push(
		`rgb(${Math.floor(RAIN_COLOR.r * brightness)},${
			Math.floor(
				RAIN_COLOR.g * brightness,
			)
		},${Math.floor(RAIN_COLOR.b * brightness)})`,
	);
	ALPHA_STAR_COLORS.push(
		`rgba(${Math.floor(RAIN_COLOR.r * brightness)},${
			Math.floor(
				RAIN_COLOR.g * brightness,
			)
		},${Math.floor(RAIN_COLOR.b * brightness)},`,
	);
}

const SPLASH_PARTICLE_COLOR = `rgb(${RAIN_COLOR.r * 0.9},${
	RAIN_COLOR.g * 0.95
},${RAIN_COLOR.b * 1.1})`;
const SPLASH_RIPPLE_COLOR = `rgb(${RAIN_COLOR.r * 0.7},${RAIN_COLOR.g * 0.8},${
	RAIN_COLOR.b * 0.95
})`;

function createParticle(): Particle {
	return {
		offsetX: (Math.random() - 0.5) * PARTICLE_OFFSET_RANGE,
		offsetY: -(Math.random() * PARTICLE_Y_RANGE + PARTICLE_Y_MIN),
		velocityX: (Math.random() - 0.5) * PARTICLE_VX_RANGE,
		velocityY: -(Math.random() * PARTICLE_VY_RANGE + PARTICLE_VY_MIN),
	};
}

function createSplash(x: number, y: number): Splash {
	return {
		x,
		y,
		age: 0,
		maxAge: 12,
		particles: Array.from(
			{ length: Math.floor(Math.random() * 3) + 3 },
			createParticle,
		),
	};
}

function createRaindrop(gridWidth: number, gridHeight: number): Raindrop {
	const bias = Math.random() * Math.random();
	return {
		x: Math.floor(Math.random() * gridWidth),
		y: Math.floor(Math.random() * gridHeight) - gridHeight,
		length: Math.floor(Math.random() * 4) + 2,
		speed: RAIN_SPEED * (Math.random() * 2 + 1),
		opacity: Math.random() * 0.3 + 0.6,
		floorHeight: window.innerHeight *
			(FLOOR_HEIGHT_MIN + bias * FLOOR_HEIGHT_RANGE),
	};
}

function createStar(): Star {
	const brightness = Math.random() * STAR_BRIGHTNESS_RANGE +
		STAR_BRIGHTNESS_MIN;
	const colorIndex = Math.floor(
		((brightness - STAR_BRIGHTNESS_MIN) / STAR_BRIGHTNESS_RANGE) *
			(NUM_COLOR_VARIATIONS - 1),
	);

	return {
		x: Math.random() * window.innerWidth,
		y: Math.random() * window.innerHeight,
		size: Math.random() > STAR_LARGE_THRESHOLD
			? STAR_SIZE_LARGE
			: STAR_SIZE_SMALL,
		twinkleSpeed: Math.random() * STAR_TWINKLE_RANGE + STAR_TWINKLE_MIN,
		twinkleOffset: Math.random() * Math.PI * 2,
		brightness,
		age: 0,
		maxAge: STAR_AGE_MIN + Math.random() * STAR_AGE_RANGE,
		canVanish: false,
		colorIndex,
	};
}

function updateParticle(p: Particle) {
	p.offsetX += p.velocityX;
	p.offsetY += p.velocityY;
	p.velocityY += PARTICLE_GRAVITY;
}

function updateSplash(splash: Splash) {
	splash.age++;
	splash.particles.forEach(updateParticle);
}

function updateRaindrop(
	drop: Raindrop,
	splashes: Splash[],
	gridWidth: number,
	gridHeight: number,
) {
	drop.y += drop.speed;
	const dropY = drop.y * PIXEL_SIZE;

	if (dropY >= drop.floorHeight && Math.random() < SPLASH_CHANCE) {
		splashes.push(createSplash(drop.x, Math.floor(dropY / PIXEL_SIZE)));
		Object.assign(drop, createRaindrop(gridWidth, gridHeight));
	} else if (dropY > window.innerHeight) {
		Object.assign(drop, createRaindrop(gridWidth, gridHeight));
	}
}

function updateStar(star: Star): void {
	star.age++;
	const phase = (star.age * star.twinkleSpeed + star.twinkleOffset) %
		(2 * Math.PI);

	if (!star.canVanish && phase < star.twinkleSpeed) {
		star.canVanish = true;
	}

	if (star.canVanish && star.age > star.maxAge) {
		Object.assign(star, createStar());
	} else if (star.x > window.innerWidth || star.y > window.innerHeight) {
		Object.assign(star, createStar());
	}
}

const isSplashDead = (splash: Splash): boolean => splash.age >= splash.maxAge;

const drawRaindrop = (ctx: CanvasRenderingContext2D, drop: Raindrop): void => {
	ctx.globalAlpha = drop.opacity;
	const xPos = drop.x * PIXEL_SIZE;

	for (let i = 0; i < drop.length; i++) {
		const colorIndex = Math.floor(
			(i / drop.length) * NUM_COLOR_VARIATIONS * 0.5,
		);
		ctx.fillStyle = RAIN_COLORS[colorIndex];
		ctx.fillRect(xPos, (drop.y - i) * PIXEL_SIZE, PIXEL_SIZE, PIXEL_SIZE);
	}
	ctx.globalAlpha = 1;
};

function drawSplash(ctx: CanvasRenderingContext2D, splash: Splash) {
	if (splash.age >= splash.maxAge) return;

	const progress = splash.age / splash.maxAge;
	ctx.globalAlpha = 1 - progress;

	ctx.fillStyle = SPLASH_PARTICLE_COLOR;
	splash.particles.forEach((p) => {
		const px = Math.floor(splash.x + p.offsetX) * PIXEL_SIZE;
		const py = Math.floor(splash.y + p.offsetY) * PIXEL_SIZE;
		ctx.fillRect(px, py, PIXEL_SIZE, PIXEL_SIZE);
	});

	if (splash.age < 6) {
		const rippleSize = Math.floor(splash.age / 2) + 1;
		const splashY = splash.y * PIXEL_SIZE;
		ctx.fillStyle = SPLASH_RIPPLE_COLOR;
		ctx.fillRect(
			(splash.x - rippleSize) * PIXEL_SIZE,
			splashY,
			PIXEL_SIZE,
			PIXEL_SIZE,
		);
		ctx.fillRect(
			(splash.x + rippleSize) * PIXEL_SIZE,
			splashY,
			PIXEL_SIZE,
			PIXEL_SIZE,
		);
	}

	ctx.globalAlpha = 1;
}

function drawStar(ctx: CanvasRenderingContext2D, star: Star, time: number) {
	const twinkle = Math.sin(time * star.twinkleSpeed + star.twinkleOffset) *
			STAR_TWINKLE_AMPLITUDE +
		STAR_TWINKLE_BASE;
	const alpha = star.brightness * twinkle;

	const x = Math.floor(star.x);
	const y = Math.floor(star.y);
	const color = ALPHA_STAR_COLORS[star.colorIndex];

	ctx.fillStyle = color + alpha;
	ctx.fillRect(x, y, star.size, star.size);

	if (star.size === STAR_SIZE_LARGE && alpha > STAR_GLOW_THRESHOLD) {
		ctx.fillStyle = color + alpha * STAR_GLOW_ALPHA;
		ctx.fillRect(x - 4, y + 2, 4, 4);
		ctx.fillRect(x + 8, y + 2, 4, 4);
		ctx.fillRect(x + 2, y - 4, 4, 4);
		ctx.fillRect(x + 2, y + 8, 4, 4);
	}
}

export default function Rain() {
	const canvasRef = useRef<HTMLCanvasElement>(null);

	useEffect(() => {
		const prefersReducedMotion = window.matchMedia(
			"(prefers-reduced-motion: reduce)",
		).matches;
		if (prefersReducedMotion) return;

		const canvas = canvasRef.current;
		if (!canvas) return;

		const ctx = canvas.getContext("2d");
		if (!ctx) return;

		let gridWidth = 0;
		let gridHeight = 0;

		const resizeCanvas = () => {
			canvas.width = window.innerWidth;
			canvas.height = window.innerHeight;
			gridWidth = Math.floor(window.innerWidth / PIXEL_SIZE);
			gridHeight = Math.floor(window.innerHeight / PIXEL_SIZE);
		};

		resizeCanvas();
		window.addEventListener("resize", resizeCanvas);

		const stars = Array.from({ length: STAR_COUNT }, createStar);
		const raindrops = Array.from(
			{ length: DROP_COUNT },
			() => createRaindrop(gridWidth, gridHeight),
		);
		const splashes: Splash[] = [];

		const startTime = performance.now();

		const animate = (time: number) => {
			ctx.fillStyle = BACKGROUND_COLOUR;
			ctx.fillRect(0, 0, canvas.width, canvas.height);

			for (let i = 0; i < stars.length; i++) {
				updateStar(stars[i]);
				drawStar(ctx, stars[i], time - startTime);
			}
			raindrops.forEach((drop) =>
				updateRaindrop(drop, splashes, gridWidth, gridHeight)
			);
			for (let i = splashes.length - 1; i >= 0; i--) {
				updateSplash(splashes[i]);
				if (isSplashDead(splashes[i])) {
					splashes.splice(i, 1);
				}
			}

			for (let i = 0; i < stars.length; i++) {
				updateStar(stars[i]);
				drawStar(ctx, stars[i], performance.now() - startTime);
			}
			raindrops.forEach((drop) => drawRaindrop(ctx, drop));
			splashes.forEach((splash) => drawSplash(ctx, splash));

			requestAnimationFrame(animate);
		};

		animate(0);
	}, []);

	return (
		<canvas
			ref={canvasRef}
			aria-hidden="true"
			role="presentation"
			style={{
				position: "fixed",
				top: 0,
				left: 0,
				width: "100vw",
				height: "100vh",
				imageRendering: "pixelated",
				background: BACKGROUND_COLOUR,
			}}
		/>
	);
}
