import 'dart:math' as math;

import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_entities.dart';

abstract class RaycastBackend {
  List<double> get zBuffer;
  int get projectionWidth;
  int get projectionOffsetX;
  int get projectionViewHeight;
  int get viewHeight;
  double get aspectMultiplier;
  double get verticalStretch;

  int wallTexY(int y, int columnHeight);
  int spriteTexY(int y, int drawStartY, int spriteHeight);

  void drawWallColumn(
    int x,
    int drawStart,
    int drawEnd,
    int columnHeight,
    Sprite texture,
    int texX,
    double perpWallDist,
    int side,
  );

  void drawSpriteStripe(
    int stripeX,
    int drawStartY,
    int drawEndY,
    int spriteHeight,
    Sprite texture,
    int texX,
    double transformY,
  );
}

/// Shared Wolf3D wall DDA and sprite projection math.
class Raycaster {
  final List<Entity> _spriteScratch = <Entity>[];

  void castWorld(WolfEngine engine, RaycastBackend backend) {
    _castWalls(engine, backend);
    _castSprites(engine, backend);
  }

  ({double distance, int side, int hitWallId, double wallX})?
  _intersectActivePushwall(
    Player player,
    double rayDirX,
    double rayDirY,
    Pushwall activePushwall,
  ) {
    double minX = activePushwall.x.toDouble();
    double maxX = activePushwall.x + 1.0;
    double minY = activePushwall.y.toDouble();
    double maxY = activePushwall.y + 1.0;

    if (activePushwall.dirX != 0) {
      final double delta = activePushwall.dirX * activePushwall.offset;
      minX += delta;
      maxX += delta;
    }

    if (activePushwall.dirY != 0) {
      final double delta = activePushwall.dirY * activePushwall.offset;
      minY += delta;
      maxY += delta;
    }

    const double epsilon = 1e-9;

    double tMinX = double.negativeInfinity;
    double tMaxX = double.infinity;
    if (rayDirX.abs() < epsilon) {
      if (player.x < minX || player.x > maxX) {
        return null;
      }
    } else {
      final double tx1 = (minX - player.x) / rayDirX;
      final double tx2 = (maxX - player.x) / rayDirX;
      tMinX = math.min(tx1, tx2);
      tMaxX = math.max(tx1, tx2);
    }

    double tMinY = double.negativeInfinity;
    double tMaxY = double.infinity;
    if (rayDirY.abs() < epsilon) {
      if (player.y < minY || player.y > maxY) {
        return null;
      }
    } else {
      final double ty1 = (minY - player.y) / rayDirY;
      final double ty2 = (maxY - player.y) / rayDirY;
      tMinY = math.min(ty1, ty2);
      tMaxY = math.max(ty1, ty2);
    }

    final double entryDistance = math.max(tMinX, tMinY);
    final double exitDistance = math.min(tMaxX, tMaxY);

    if (exitDistance < 0 || entryDistance > exitDistance) {
      return null;
    }

    final double hitDistance = entryDistance >= 0
        ? entryDistance
        : exitDistance;
    if (hitDistance < 0) {
      return null;
    }

    final int side = tMinX > tMinY ? 0 : 1;
    final double wallCoord = side == 0
        ? player.y + hitDistance * rayDirY
        : player.x + hitDistance * rayDirX;

    return (
      distance: hitDistance,
      side: side,
      hitWallId: activePushwall.mapId,
      wallX: wallCoord - wallCoord.floor(),
    );
  }

  void _castWalls(WolfEngine engine, RaycastBackend backend) {
    final Player player = engine.player;
    final SpriteMap map = engine.currentLevel;
    final List<Sprite> wallTextures = engine.data.walls;
    final int sceneWidth = backend.projectionWidth;
    final int sceneHeight = backend.projectionViewHeight;
    final Pushwall? activePushwall = engine.pushwallManager.activePushwall;

    const double fovHalfTan = 0.5773502691896257; // tan(PI / 6)
    final double cosAngle = math.cos(player.angle);
    final double sinAngle = math.sin(player.angle);

    final double dirX = cosAngle;
    final double dirY = sinAngle;
    final double planeX = -dirY * fovHalfTan;
    final double planeY = dirX * fovHalfTan;

    for (int x = 0; x < sceneWidth; x++) {
      final double cameraX = 2 * x / sceneWidth - 1.0;
      final double rayDirX = dirX + (planeX * cameraX);
      final double rayDirY = dirY + (planeY * cameraX);

      final pushwallHit = activePushwall == null
          ? null
          : _intersectActivePushwall(player, rayDirX, rayDirY, activePushwall);

      int mapX = player.x.toInt();
      int mapY = player.y.toInt();

      final double deltaDistX = (rayDirX == 0) ? 1e30 : (1.0 / rayDirX).abs();
      final double deltaDistY = (rayDirY == 0) ? 1e30 : (1.0 / rayDirY).abs();

      late double sideDistX;
      late double sideDistY;
      double perpWallDist = 0.0;
      int stepX;
      int stepY;
      int side = 0;
      int hitWallId = 0;
      bool hit = false;
      bool hitOutOfBounds = false;
      bool customDistCalculated = false;
      double textureOffset = 0.0;
      double? wallXOverride;

      if (rayDirX < 0) {
        stepX = -1;
        sideDistX = (player.x - mapX) * deltaDistX;
      } else {
        stepX = 1;
        sideDistX = (mapX + 1.0 - player.x) * deltaDistX;
      }
      if (rayDirY < 0) {
        stepY = -1;
        sideDistY = (player.y - mapY) * deltaDistY;
      } else {
        stepY = 1;
        sideDistY = (mapY + 1.0 - player.y) * deltaDistY;
      }

      while (!hit) {
        if (sideDistX < sideDistY) {
          sideDistX += deltaDistX;
          mapX += stepX;
          side = 0;
        } else {
          sideDistY += deltaDistY;
          mapY += stepY;
          side = 1;
        }

        if (mapY < 0 ||
            mapY >= map.length ||
            mapX < 0 ||
            mapX >= map[0].length) {
          hit = true;
          hitOutOfBounds = true;
        } else if (map[mapY][mapX] > 0) {
          if (activePushwall != null &&
              mapX == activePushwall.x &&
              mapY == activePushwall.y) {
            continue;
          }

          if (map[mapY][mapX] >= 90) {
            final double currentOffset = engine.doorManager.doorOffsetAt(
              mapX,
              mapY,
            );
            if (currentOffset > 0.0) {
              final double perpWallDistTemp = side == 0
                  ? (sideDistX - deltaDistX)
                  : (sideDistY - deltaDistY);
              double wallXTemp = side == 0
                  ? player.y + perpWallDistTemp * rayDirY
                  : player.x + perpWallDistTemp * rayDirX;
              wallXTemp -= wallXTemp.floor();
              if (wallXTemp < currentOffset) {
                continue;
              }
            }
            hit = true;
            hitWallId = map[mapY][mapX];
            textureOffset = currentOffset;
          } else {
            hit = true;
            hitWallId = map[mapY][mapX];
          }
        }
      }

      if (hitOutOfBounds || !hit) {
        if (pushwallHit == null) {
          continue;
        }

        customDistCalculated = true;
        perpWallDist = pushwallHit.distance;
        side = pushwallHit.side;
        hitWallId = pushwallHit.hitWallId;
        wallXOverride = pushwallHit.wallX;
        textureOffset = 0.0;
      }

      if (!customDistCalculated) {
        perpWallDist = side == 0
            ? (sideDistX - deltaDistX)
            : (sideDistY - deltaDistY);
      }

      if (pushwallHit != null && pushwallHit.distance < perpWallDist) {
        perpWallDist = pushwallHit.distance;
        side = pushwallHit.side;
        hitWallId = pushwallHit.hitWallId;
        wallXOverride = pushwallHit.wallX;
        textureOffset = 0.0;
      }

      if (perpWallDist < 0.1) {
        perpWallDist = 0.1;
      }

      backend.zBuffer[x] = perpWallDist;

      double wallX =
          wallXOverride ??
          (side == 0
              ? player.y + perpWallDist * rayDirY
              : player.x + perpWallDist * rayDirX);
      wallX -= wallX.floor();

      int texNum;
      if (hitWallId >= 90) {
        texNum = 98.clamp(0, wallTextures.length - 1);
      } else {
        texNum = ((hitWallId - 1) * 2).clamp(0, wallTextures.length - 2);
        if (side == 1) {
          texNum += 1;
        }
      }
      final Sprite texture = wallTextures[texNum];

      int texX = (((wallX - textureOffset) % 1.0) * 64).toInt().clamp(0, 63);
      if (side == 0 && cosAngle > 0) {
        texX = 63 - texX;
      }
      if (side == 1 && sinAngle < 0) {
        texX = 63 - texX;
      }

      final int columnHeight =
          ((sceneHeight / perpWallDist) * backend.verticalStretch).toInt();
      final int drawStart = (-columnHeight ~/ 2 + sceneHeight ~/ 2).clamp(
        0,
        sceneHeight,
      );
      final int drawEnd = (columnHeight ~/ 2 + sceneHeight ~/ 2).clamp(
        0,
        sceneHeight,
      );

      backend.drawWallColumn(
        backend.projectionOffsetX + x,
        drawStart,
        drawEnd,
        columnHeight,
        texture,
        texX,
        perpWallDist,
        side,
      );
    }
  }

  void _castSprites(WolfEngine engine, RaycastBackend backend) {
    final Player player = engine.player;
    final int sceneWidth = backend.projectionWidth;
    final int sceneHeight = backend.projectionViewHeight;
    final double playerX = player.position.x;
    final double playerY = player.position.y;

    final double cosAngle = math.cos(player.angle);
    final double sinAngle = math.sin(player.angle);
    const double fovHalfTan = 0.5773502691896257; // tan(PI / 6)

    final double dirX = cosAngle;
    final double dirY = sinAngle;
    final double planeX = -dirY * fovHalfTan;
    final double planeY = dirX * fovHalfTan;

    _spriteScratch.clear();
    for (final Entity entity in engine.entities) {
      final double toSpriteX = entity.position.x - playerX;
      final double toSpriteY = entity.position.y - playerY;
      // Reject sprites behind the player before sort/projection work.
      if ((toSpriteX * dirX + toSpriteY * dirY) > 0) {
        _spriteScratch.add(entity);
      }
    }

    _spriteScratch.sort((a, b) {
      final double adx = a.position.x - playerX;
      final double ady = a.position.y - playerY;
      final double bdx = b.position.x - playerX;
      final double bdy = b.position.y - playerY;
      final double distA = (adx * adx) + (ady * ady);
      final double distB = (bdx * bdx) + (bdy * bdy);
      return distB.compareTo(distA);
    });

    for (final Entity entity in _spriteScratch) {
      final double spriteX = entity.position.x - player.position.x;
      final double spriteY = entity.position.y - player.position.y;

      final double invDet = 1.0 / (planeX * dirY - dirX * planeY);
      final double transformX = invDet * (dirY * spriteX - dirX * spriteY);
      final double transformY = invDet * (-planeY * spriteX + planeX * spriteY);

      if (transformY <= 0) {
        continue;
      }

      final int spriteScreenX =
          ((sceneWidth / 2) * (1 + transformX / transformY)).toInt();
      final int spriteHeight =
          ((sceneHeight / transformY).abs() * backend.verticalStretch).toInt();
      final int displayedSpriteHeight =
          ((backend.viewHeight / transformY).abs() * backend.verticalStretch)
              .toInt();

      final int spriteWidth =
          (displayedSpriteHeight *
                  backend.aspectMultiplier /
                  backend.verticalStretch)
              .toInt();

      final int drawStartY = -spriteHeight ~/ 2 + sceneHeight ~/ 2;
      final int drawEndY = spriteHeight ~/ 2 + sceneHeight ~/ 2;
      final int drawStartX = -spriteWidth ~/ 2 + spriteScreenX;
      final int drawEndX = spriteWidth ~/ 2 + spriteScreenX;

      final int clipStartX = math.max(0, drawStartX);
      final int clipEndX = math.min(sceneWidth, drawEndX);

      final int safeIndex = entity.spriteIndex.clamp(
        0,
        engine.data.sprites.length - 1,
      );
      final Sprite texture = engine.data.sprites[safeIndex];

      for (int stripe = clipStartX; stripe < clipEndX; stripe++) {
        if (transformY < backend.zBuffer[stripe]) {
          final int texX = ((stripe - drawStartX) * 64 ~/ spriteWidth).clamp(
            0,
            63,
          );

          backend.drawSpriteStripe(
            backend.projectionOffsetX + stripe,
            drawStartY,
            drawEndY,
            spriteHeight,
            texture,
            texX,
            transformY,
          );
        }
      }
    }
  }
}
