import {
  colorMaskToHex,
  colorMaskToNameOrNone,
  colorNameToMask,
  cyclePrismColor
} from "./colors";
import type {
  BeamColorName,
  DetectorData,
  DeviceData,
  Direction,
  EmitterData,
  GameSnapshot,
  LaserSegment,
  LevelData,
  Vec2
} from "./types";

const PLAYER_RADIUS = 0.22;
const DEVICE_RADIUS = 0.24;
const DETECTOR_RADIUS = 0.23;
const PORTAL_RADIUS = 0.32;
const MOVE_SPEED = 4.1; // cells per second
const MOVE_ACCEL = 12.5; // cells per second squared
const MOVE_DECEL = 10.0; // cells per second squared
const MOVE_SUBSTEP = 0.08;
const MIRROR_HALF_LENGTH = 0.24;
const EPSILON = 1e-4;

const DIR_ANGLE: Record<Direction, number> = {
  right: 0,
  down: 90,
  left: 180,
  up: 270
};

interface BeamRay {
  origin: Vec2;
  dir: Vec2;
  colorMask: number;
  rayId: string;
}

interface RayHit {
  point: Vec2;
  type: "wall" | "device" | "detector";
  device?: DeviceData;
  detector?: DetectorData;
  distance: number;
}

function toRadians(deg: number): number {
  return (deg * Math.PI) / 180;
}

function normalizeAngle(deg: number): number {
  const value = deg % 360;
  return value < 0 ? value + 360 : value;
}

function vecFromAngle(deg: number): Vec2 {
  const rad = toRadians(deg);
  return { x: Math.cos(rad), y: Math.sin(rad) };
}

function length(vec: Vec2): number {
  return Math.hypot(vec.x, vec.y);
}

function normalize(vec: Vec2): Vec2 {
  const len = length(vec);
  if (len < EPSILON) {
    return { x: 0, y: 0 };
  }
  return { x: vec.x / len, y: vec.y / len };
}

function dot(a: Vec2, b: Vec2): number {
  return a.x * b.x + a.y * b.y;
}

function cross(a: Vec2, b: Vec2): number {
  return a.x * b.y - a.y * b.x;
}

function sub(a: Vec2, b: Vec2): Vec2 {
  return { x: a.x - b.x, y: a.y - b.y };
}

function distance(a: Vec2, b: Vec2): number {
  return length(sub(a, b));
}

function reflect(direction: Vec2, mirrorDeg: number): Vec2 {
  const tangent = vecFromAngle(mirrorDeg);
  const normal = normalize({ x: -tangent.y, y: tangent.x });
  const d = normalize(direction);
  const k = 2 * dot(d, normal);
  return normalize({ x: d.x - k * normal.x, y: d.y - k * normal.y });
}

function circleIntersectsRect(center: Vec2, radius: number, rectX: number, rectY: number, rectW: number, rectH: number): boolean {
  const closestX = Math.max(rectX, Math.min(center.x, rectX + rectW));
  const closestY = Math.max(rectY, Math.min(center.y, rectY + rectH));
  const dx = center.x - closestX;
  const dy = center.y - closestY;
  return dx * dx + dy * dy < radius * radius;
}

function rayCircleIntersection(origin: Vec2, dir: Vec2, center: Vec2, radius: number): number | null {
  const m = sub(origin, center);
  const b = dot(m, dir);
  const c = dot(m, m) - radius * radius;

  if (c > 0 && b > 0) {
    return null;
  }

  const discriminant = b * b - c;
  if (discriminant < 0) {
    return null;
  }

  const t = -b - Math.sqrt(discriminant);
  if (t < EPSILON) {
    const t2 = -b + Math.sqrt(discriminant);
    return t2 > EPSILON ? t2 : null;
  }
  return t;
}

function rayAabbIntersection(origin: Vec2, dir: Vec2, minX: number, minY: number, maxX: number, maxY: number): number | null {
  let tmin = -Infinity;
  let tmax = Infinity;

  if (Math.abs(dir.x) < EPSILON) {
    if (origin.x <= minX || origin.x >= maxX) {
      return null;
    }
  } else {
    const tx1 = (minX - origin.x) / dir.x;
    const tx2 = (maxX - origin.x) / dir.x;
    tmin = Math.max(tmin, Math.min(tx1, tx2));
    tmax = Math.min(tmax, Math.max(tx1, tx2));
  }

  if (Math.abs(dir.y) < EPSILON) {
    if (origin.y <= minY || origin.y >= maxY) {
      return null;
    }
  } else {
    const ty1 = (minY - origin.y) / dir.y;
    const ty2 = (maxY - origin.y) / dir.y;
    tmin = Math.max(tmin, Math.min(ty1, ty2));
    tmax = Math.min(tmax, Math.max(ty1, ty2));
  }

  if (tmax < tmin) {
    return null;
  }

  if (tmax < EPSILON) {
    return null;
  }

  if (tmin > EPSILON) {
    return tmin;
  }

  return tmax > EPSILON ? tmax : null;
}

function raySegmentIntersection(origin: Vec2, dir: Vec2, a: Vec2, b: Vec2): number | null {
  const segment = sub(b, a);
  const qmp = sub(a, origin);
  const denom = cross(dir, segment);
  if (Math.abs(denom) < EPSILON) {
    return null;
  }

  const t = cross(qmp, segment) / denom;
  const u = cross(qmp, dir) / denom;

  if (t < EPSILON) {
    return null;
  }
  if (u < -EPSILON || u > 1 + EPSILON) {
    return null;
  }
  return t;
}

function segmentIntersectsCircle(from: Vec2, to: Vec2, center: Vec2, radius: number): boolean {
  const segment = sub(to, from);
  const toCenter = sub(center, from);
  const segLenSq = dot(segment, segment);
  if (segLenSq < EPSILON) {
    return distance(from, center) <= radius;
  }

  const t = Math.max(0, Math.min(1, dot(toCenter, segment) / segLenSq));
  const closest = {
    x: from.x + segment.x * t,
    y: from.y + segment.y * t
  };
  return distance(closest, center) <= radius;
}

function rotateClockwise(deg: number): number {
  return normalizeAngle(deg + 90);
}

function moveTowards(current: number, target: number, maxDelta: number): number {
  const delta = target - current;
  if (Math.abs(delta) <= maxDelta) {
    return target;
  }
  return current + Math.sign(delta) * maxDelta;
}

export class LaserPuzzleGame {
  private readonly levels: LevelData[];

  private levelIndex = 0;

  private player: Vec2 = { x: 0, y: 0 }; // continuous grid-space coordinates

  private playerVelocity: Vec2 = { x: 0, y: 0 };

  private devices: DeviceData[] = [];

  private beams: LaserSegment[] = [];

  private detectorMaskByKey = new Map<string, number>();

  private detectorRayCountByKey = new Map<string, number>();

  private portalOpen = false;

  private campaignComplete = false;

  private statusText = "Собери нужные цветовые комбинации в детекторах и открой портал.";

  constructor(levels: LevelData[]) {
    if (levels.length < 1) {
      throw new Error("At least one level is required.");
    }
    this.levels = levels;
    this.loadLevel(0);
  }

  resetCampaign(): void {
    this.campaignComplete = false;
    this.loadLevel(0);
    this.statusText = "Кампания перезапущена.";
  }

  startFromLevel(index: number): void {
    this.campaignComplete = false;
    const clamped = Math.max(0, Math.min(this.levels.length - 1, Math.floor(index)));
    this.loadLevel(clamped);
    this.statusText = `Уровень ${this.levelIndex + 1}: ${this.currentLevel().title}`;
  }

  movePlayerAxis(inputX: number, inputY: number, dtSeconds: number): boolean {
    if (this.campaignComplete) {
      return false;
    }

    if (dtSeconds <= 0) {
      return false;
    }

    const axisRaw = { x: inputX, y: inputY };
    const hasInput = length(axisRaw) > EPSILON;
    const axis = hasInput ? normalize(axisRaw) : { x: 0, y: 0 };
    const targetVelocity = {
      x: axis.x * MOVE_SPEED,
      y: axis.y * MOVE_SPEED
    };

    const maxVelocityDelta = (hasInput ? MOVE_ACCEL : MOVE_DECEL) * dtSeconds;
    this.playerVelocity = {
      x: moveTowards(this.playerVelocity.x, targetVelocity.x, maxVelocityDelta),
      y: moveTowards(this.playerVelocity.y, targetVelocity.y, maxVelocityDelta)
    };

    if (Math.abs(this.playerVelocity.x) < 0.001) {
      this.playerVelocity.x = 0;
    }
    if (Math.abs(this.playerVelocity.y) < 0.001) {
      this.playerVelocity.y = 0;
    }

    const step = length(this.playerVelocity) * dtSeconds;
    if (step < EPSILON) {
      return false;
    }

    const iterations = Math.max(1, Math.ceil(step / MOVE_SUBSTEP));
    let stepX = (this.playerVelocity.x * dtSeconds) / iterations;
    let stepY = (this.playerVelocity.y * dtSeconds) / iterations;
    let moved = false;

    for (let i = 0; i < iterations; i += 1) {
      if (Math.abs(stepX) > EPSILON) {
        const tryX = { x: this.player.x + stepX, y: this.player.y };
        if (!this.collidesPlayer(tryX)) {
          this.player = tryX;
          moved = true;
        } else {
          this.playerVelocity.x = 0;
          stepX = 0;
        }
      }

      if (Math.abs(stepY) > EPSILON) {
        const tryY = { x: this.player.x, y: this.player.y + stepY };
        if (!this.collidesPlayer(tryY)) {
          this.player = tryY;
          moved = true;
        } else {
          this.playerVelocity.y = 0;
          stepY = 0;
        }
      }
    }

    if (moved) {
      this.tryEnterPortal();
    }

    return moved;
  }

  rotateAdjacent(deltaDegrees: number): boolean {
    if (this.campaignComplete || Math.abs(deltaDegrees) < EPSILON) {
      return false;
    }

    const device = this.findNearestNearbyDevice();
    if (!device) {
      return false;
    }

    device.rot = normalizeAngle(device.rot + deltaDegrees);
    this.syncLaserState();
    return true;
  }

  setAdjacentRotation(angleDegrees: number): boolean {
    if (this.campaignComplete || !Number.isFinite(angleDegrees)) {
      return false;
    }

    const device = this.findNearestNearbyDevice();
    if (!device) {
      return false;
    }

    device.rot = normalizeAngle(angleDegrees);
    this.syncLaserState();
    return true;
  }

  repaintAdjacentPrism(delta: number): boolean {
    if (this.campaignComplete || delta === 0) {
      return false;
    }

    const prism = this.findNearestNearbyPrism();
    if (!prism) {
      this.statusText = "Рядом нет призмы для окрашивания.";
      return false;
    }

    prism.tint = cyclePrismColor(prism.tint ?? "white", delta);
    this.syncLaserState();
    this.statusText = `Призма ${prism.id} окрашена в ${this.colorNameRu(prism.tint)}.`;
    return true;
  }

  getSnapshot(): GameSnapshot {
    const level = this.currentLevel();
    const requiredDetectorCount = this.requiredDetectorCount(level);

    const detectors = level.detectors.map((detector) => {
      const key = this.pointKey(detector.x, detector.y);
      const currentMask = this.detectorMaskByKey.get(key) ?? 0;
      const requiredMask = colorNameToMask(detector.requiredColor);
      const currentRayCount = this.detectorRayCountByKey.get(key) ?? 0;
      const requiredRayCount = Math.max(1, detector.minRays ?? 1);
      return {
        ...detector,
        currentColor: colorMaskToNameOrNone(currentMask),
        currentRayCount,
        requiredRayCount,
        matched: currentMask === requiredMask && currentRayCount >= requiredRayCount
      };
    });

    const matchedDetectorCount = detectors.filter((detector) => detector.matched).length;

    return {
      levelIndex: this.levelIndex,
      levelCount: this.levels.length,
      level,
      player: { ...this.player },
      devices: this.devices.map((device) => ({ ...device })),
      beams: this.beams.map((segment) => ({
        from: { ...segment.from },
        to: { ...segment.to },
        color: segment.color
      })),
      detectors,
      matchedDetectorCount,
      requiredDetectorCount,
      portalOpen: this.portalOpen,
      campaignComplete: this.campaignComplete,
      statusText: this.statusText,
      activeDeviceId: this.findNearestNearbyDevice()?.id ?? null
    };
  }

  private currentLevel(): LevelData {
    return this.levels[this.levelIndex];
  }

  private loadLevel(index: number): void {
    this.levelIndex = index;
    const level = this.currentLevel();

    this.player = {
      x: level.playerStart.x + 0.5,
      y: level.playerStart.y + 0.5
    };
    this.playerVelocity = { x: 0, y: 0 };

    this.devices = level.devices.map((device) => {
      const raw = device.rot;
      const asQuarterTurn = Math.abs(raw) <= 3 && Number.isInteger(raw);
      let angle = raw;
      if (asQuarterTurn) {
        angle = device.type === "mirror"
          ? 45 + raw * 90
          : raw * 90;
      }
      return {
        ...device,
        rot: normalizeAngle(angle),
        tint: device.type === "prism" ? device.tint ?? "white" : device.tint
      };
    });

    this.syncLaserState();
    this.statusText = this.portalOpen
      ? "Портал активирован. Доберись до него!"
      : "Свободно перемещайся, вращай оптику и смешивай лучи в детекторах.";
  }

  private advanceLevel(): void {
    if (this.levelIndex >= this.levels.length - 1) {
      this.campaignComplete = true;
      this.portalOpen = true;
      this.statusText = "Ты прошёл все уровни! Нажми Enter, чтобы начать заново.";
      return;
    }

    this.loadLevel(this.levelIndex + 1);
    this.statusText = `Уровень ${this.levelIndex + 1}: ${this.currentLevel().title}`;
  }

  private tryEnterPortal(): void {
    const level = this.currentLevel();
    const portalCenter = { x: level.portal.x + 0.5, y: level.portal.y + 0.5 };
    if (this.portalOpen && distance(this.player, portalCenter) <= PLAYER_RADIUS + PORTAL_RADIUS * 0.45) {
      this.advanceLevel();
    }
  }

  private collidesPlayer(position: Vec2): boolean {
    const level = this.currentLevel();

    if (position.x < PLAYER_RADIUS || position.y < PLAYER_RADIUS) {
      return true;
    }
    if (position.x > level.width - PLAYER_RADIUS || position.y > level.height - PLAYER_RADIUS) {
      return true;
    }

    for (const wall of level.walls) {
      if (circleIntersectsRect(position, PLAYER_RADIUS, wall.x, wall.y, 1, 1)) {
        return true;
      }
    }

    for (const device of this.devices) {
      const center = { x: device.x + 0.5, y: device.y + 0.5 };
      if (distance(position, center) < PLAYER_RADIUS + DEVICE_RADIUS) {
        return true;
      }
    }

    if (!this.portalOpen) {
      const portalCenter = { x: level.portal.x + 0.5, y: level.portal.y + 0.5 };
      if (distance(position, portalCenter) < PLAYER_RADIUS + PORTAL_RADIUS * 0.7) {
        return true;
      }
    }

    return false;
  }

  private findNearestNearbyDevice(): DeviceData | null {
    const candidates = this.devices
      .map((device) => {
        const center = { x: device.x + 0.5, y: device.y + 0.5 };
        return {
          device,
          distance: distance(this.player, center)
        };
      })
      .filter((candidate) => candidate.distance <= 1.25)
      .sort((a, b) => a.distance - b.distance || a.device.id.localeCompare(b.device.id));

    return candidates[0]?.device ?? null;
  }

  private findNearestNearbyPrism(): DeviceData | null {
    const candidates = this.devices
      .filter((device) => device.type === "prism")
      .map((device) => {
        const center = { x: device.x + 0.5, y: device.y + 0.5 };
        return {
          device,
          distance: distance(this.player, center)
        };
      })
      .filter((candidate) => candidate.distance <= 1.25)
      .sort((a, b) => a.distance - b.distance || a.device.id.localeCompare(b.device.id));

    return candidates[0]?.device ?? null;
  }

  private requiredDetectorCount(level: LevelData): number {
    const required = level.requiredDetectors ?? level.detectors.length;
    return Math.max(1, Math.min(required, level.detectors.length));
  }

  private pointKey(x: number, y: number): string {
    return `${x},${y}`;
  }

  private colorNameRu(name: BeamColorName): string {
    if (name === "red") {
      return "красный";
    }
    if (name === "green") {
      return "зелёный";
    }
    if (name === "blue") {
      return "синий";
    }
    if (name === "yellow") {
      return "жёлтый";
    }
    if (name === "magenta") {
      return "пурпурный";
    }
    if (name === "cyan") {
      return "голубой";
    }
    return "белый";
  }

  private syncLaserState(): void {
    const level = this.currentLevel();
    const result = this.traceLaser();
    this.beams = result.segments;
    this.detectorMaskByKey = result.detectorMaskByKey;
    this.detectorRayCountByKey = result.detectorRayCountByKey;

    let matched = 0;
    for (const detector of level.detectors) {
      const key = this.pointKey(detector.x, detector.y);
      const currentMask = this.detectorMaskByKey.get(key) ?? 0;
      const currentRayCount = this.detectorRayCountByKey.get(key) ?? 0;
      const requiredRayCount = Math.max(1, detector.minRays ?? 1);
      if (currentMask === colorNameToMask(detector.requiredColor) && currentRayCount >= requiredRayCount) {
        matched += 1;
      }
    }

    this.portalOpen = matched >= this.requiredDetectorCount(level);
  }

  private traceLaser(): {
    segments: LaserSegment[];
    detectorMaskByKey: Map<string, number>;
    detectorRayCountByKey: Map<string, number>;
  } {
    const level = this.currentLevel();
    const detectorMaskByKey = new Map<string, number>();
    const detectorRayIdsByKey = new Map<string, Set<string>>();
    const segments: LaserSegment[] = [];

    const queue: BeamRay[] = this.levelEmitters(level).map((emitter, emitterIndex) => {
      const emitterAngle = emitter.angle ?? DIR_ANGLE[emitter.dir ?? "right"];
      return {
        origin: { x: emitter.x + 0.5, y: emitter.y + 0.5 },
        dir: normalize(vecFromAngle(emitterAngle)),
        colorMask: colorNameToMask(emitter.color),
        rayId: `emitter-${emitterIndex}`
      };
    });
    const spawned = new Set<string>();
    const maxBounces = level.width * level.height * 8;

    const detectorCenters = level.detectors.map((detector) => ({
      detector,
      center: { x: detector.x + 0.5, y: detector.y + 0.5 }
    }));

    while (queue.length > 0) {
      const ray = queue.shift();
      if (!ray || ray.colorMask === 0) {
        continue;
      }

      let origin = { ...ray.origin };
      let dir = normalize(ray.dir);
      let colorMask = ray.colorMask;
      let rayId = ray.rayId;

      for (let bounce = 0; bounce < maxBounces; bounce += 1) {
        const hit = this.findNearestHit(origin, dir);
        if (!hit) {
          break;
        }

        const segmentTo = hit.point;
        segments.push({
          from: { ...origin },
          to: { ...segmentTo },
          color: colorMaskToHex(colorMask)
        });

        for (const { detector, center } of detectorCenters) {
          if (segmentIntersectsCircle(origin, segmentTo, center, DETECTOR_RADIUS)) {
            const key = this.pointKey(detector.x, detector.y);
            const prev = detectorMaskByKey.get(key) ?? 0;
            detectorMaskByKey.set(key, prev | colorMask);
            const hits = detectorRayIdsByKey.get(key) ?? new Set<string>();
            hits.add(rayId);
            detectorRayIdsByKey.set(key, hits);
          }
        }

        if (hit.type === "detector" && hit.detector) {
          const key = this.pointKey(hit.detector.x, hit.detector.y);
          const prev = detectorMaskByKey.get(key) ?? 0;
          detectorMaskByKey.set(key, prev | colorMask);
          const hits = detectorRayIdsByKey.get(key) ?? new Set<string>();
          hits.add(rayId);
          detectorRayIdsByKey.set(key, hits);
        }

        if (hit.type === "wall" || hit.type === "detector") {
          break;
        }

        const device = hit.device;
        if (!device) {
          break;
        }

        if (device.type === "mirror") {
          dir = reflect(dir, device.rot);
          origin = {
            x: segmentTo.x + dir.x * EPSILON,
            y: segmentTo.y + dir.y * EPSILON
          };
          continue;
        }

        if (device.type === "prism") {
          dir = normalize(vecFromAngle(device.rot));
          colorMask &= colorNameToMask(device.tint ?? "white");
          if (colorMask === 0) {
            break;
          }
          const center = { x: device.x + 0.5, y: device.y + 0.5 };
          origin = {
            x: center.x + dir.x * (DEVICE_RADIUS + EPSILON),
            y: center.y + dir.y * (DEVICE_RADIUS + EPSILON)
          };
          continue;
        }

        const mainDir = normalize(vecFromAngle(device.rot));
        const branchDir = normalize(vecFromAngle(rotateClockwise(device.rot)));
        const center = { x: device.x + 0.5, y: device.y + 0.5 };

        const branchKey = `${device.id}:${Math.round(branchDir.x * 1000)}:${Math.round(branchDir.y * 1000)}:${colorMask}`;
        if (!spawned.has(branchKey)) {
          spawned.add(branchKey);
          queue.push({
            origin: {
              x: center.x + branchDir.x * (DEVICE_RADIUS + EPSILON),
              y: center.y + branchDir.y * (DEVICE_RADIUS + EPSILON)
            },
            dir: branchDir,
            colorMask,
            rayId: `${rayId}:split:${device.id}:${bounce}`
          });
        }

        dir = mainDir;
        origin = {
          x: center.x + mainDir.x * (DEVICE_RADIUS + EPSILON),
          y: center.y + mainDir.y * (DEVICE_RADIUS + EPSILON)
        };
      }
    }

    const detectorRayCountByKey = new Map<string, number>();
    for (const [key, rayIds] of detectorRayIdsByKey) {
      detectorRayCountByKey.set(key, rayIds.size);
    }

    return { segments, detectorMaskByKey, detectorRayCountByKey };
  }

  private levelEmitters(level: LevelData): EmitterData[] {
    if (level.emitters && level.emitters.length > 0) {
      return level.emitters;
    }
    return [level.emitter];
  }

  private findNearestHit(origin: Vec2, dir: Vec2): RayHit | null {
    const level = this.currentLevel();
    let best: RayHit | null = null;

    for (const wall of level.walls) {
      const t = rayAabbIntersection(origin, dir, wall.x, wall.y, wall.x + 1, wall.y + 1);
      if (t === null) {
        continue;
      }
      if (!best || t < best.distance) {
        best = {
          point: { x: origin.x + dir.x * t, y: origin.y + dir.y * t },
          type: "wall",
          distance: t
        };
      }
    }

    for (const device of this.devices) {
      const center = { x: device.x + 0.5, y: device.y + 0.5 };
      let t: number | null;
      if (device.type === "mirror") {
        const axis = vecFromAngle(device.rot);
        const a = {
          x: center.x - axis.x * MIRROR_HALF_LENGTH,
          y: center.y - axis.y * MIRROR_HALF_LENGTH
        };
        const b = {
          x: center.x + axis.x * MIRROR_HALF_LENGTH,
          y: center.y + axis.y * MIRROR_HALF_LENGTH
        };
        t = raySegmentIntersection(origin, dir, a, b);
      } else {
        t = rayCircleIntersection(origin, dir, center, DEVICE_RADIUS);
      }
      if (t === null) {
        continue;
      }
      if (!best || t < best.distance) {
        best = {
          point: { x: origin.x + dir.x * t, y: origin.y + dir.y * t },
          type: "device",
          device,
          distance: t
        };
      }
    }

    for (const detector of level.detectors) {
      const center = { x: detector.x + 0.5, y: detector.y + 0.5 };
      const t = rayCircleIntersection(origin, dir, center, DETECTOR_RADIUS);
      if (t === null) {
        continue;
      }
      if (!best || t < best.distance) {
        best = {
          point: { x: origin.x + dir.x * t, y: origin.y + dir.y * t },
          type: "detector",
          detector,
          distance: t
        };
      }
    }

    return best;
  }
}
