#include "raycaster.hpp"
#include "dbc.hpp"
#include "matrix.hpp"
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <fmt/core.h>
#include <memory>
#include <numbers>
#include "components.hpp"
#include "textures.hpp"
#include "systems.hpp"
#include "shaders.hpp"

using namespace fmt;
using std::make_unique;

union ColorConv {
  struct {
    uint8_t r;
    uint8_t g;
    uint8_t b;
    uint8_t a;
  } as_color;
  RGBA as_int;
};

// from: https://permadi.com/1996/05/ray-casting-tutorial-19/
// Intensity = (kI/(d+do))*(N*L)
// rcr says:  kI = intensity coefficient, d = distance, d0 = fudge term to prevent division by zero, N is surface, L is direction to light from surface
//
// That formula is just "Inverse-square law" (except they don't square, which is physically dubious), and "Lambertian reflectance" ("Diffuse reflection") which sounds fancy but is super standard. All the quoted terms have wikipedia articles
//
// Distance means distance to surface from light.
//
// Intensity = Object Intensity/Distance * Multiplier
//
/* It's hard to believe, but this is faster than any bitfiddling
 * I could devise.  Just use a union with a struct, do the math
 * and I guess the compiler can handle it better than shifting
 * bits around.
 */
inline RGBA lighting_calc(RGBA pixel, float dist, int level) {
  ColorConv conv{.as_int=pixel};

  if(conv.as_color.b < GLOW_LIMIT
      && conv.as_color.r < GLOW_LIMIT
      && conv.as_color.g < GLOW_LIMIT)
  {
    float intensity = (float(level) * PERCENT) / (dist + 1) * LIGHT_MULTIPLIER;

    conv.as_color.r *= intensity;
    conv.as_color.g *= intensity;
    conv.as_color.b *= intensity;
  }

  return conv.as_int;
}

Raycaster::Raycaster(int width, int height) :
  $view_texture(sf::Vector2u{(unsigned int)width, (unsigned int)height}),
  $view_sprite($view_texture),
  $width(width), $height(height),
  $zbuffer(width)
{
  $view_sprite.setPosition({0, 0});
  $pixels = make_unique<RGBA[]>($width * $height);
  $view_texture.setSmooth(false);
}

void Raycaster::set_position(int x, int y) {
  $screen_pos_x = x;
  $screen_pos_y = y;
  $view_sprite.setPosition({(float)x, (float)y});
}

void Raycaster::position_camera(float player_x, float player_y) {
  // x and y start position
  $pos_x = player_x;
  $pos_y = player_y;
  $dir_x = 1;
  $dir_y = 0;
  $plane_x = 0;
  $plane_y = 0.66;

  // BUG: make this a function?
  aiming_at = { size_t($pos_x + $dir_x), size_t($pos_y + $dir_y) };
}

void Raycaster::draw_pixel_buffer() {
  $view_texture.update((uint8_t *)$pixels.get(), {(unsigned int)$width, (unsigned int)$height}, {0, 0});
}


void Raycaster::apply_sprite_effect(shared_ptr<sf::Shader> effect, float width, float height) {
  effect->setUniform("u_time", $clock.getElapsedTime().asSeconds());
  sf::Vector2f u_resolution{width, height};
  effect->setUniform("u_resolution", u_resolution);
}


void Raycaster::sprite_casting(sf::RenderTarget &target) {
  auto& lights = $level.lights->lighting();

  // sort sprites from far to close
  auto sprite_order = $level.collision->distance_sorted({(size_t)$pos_x, (size_t)$pos_y}, 500);

  // after sorting the sprites, do the projection
  for(auto& rec : sprite_order) {
    if(!$sprites.contains(rec.second)) continue;

    auto& sprite_texture = $sprites.at(rec.second);

    int texture_width = (float)sprite_texture.frame_size.x;
    int texture_height =(float)sprite_texture.frame_size.y;
    int half_height = texture_height / 2;

    auto& sf_sprite = sprite_texture.sprite;
    auto sprite_pos = $level.world->get<components::Position>(rec.second);

    double sprite_x = double(sprite_pos.location.x) - $pos_x + 0.5;
    double sprite_y = double(sprite_pos.location.y) - $pos_y + 0.5;

    double inv_det = 1.0 / ($plane_x * $dir_y - $dir_x * $plane_y); // required for correct matrix multiplication

    double transform_x = inv_det * ($dir_y * sprite_x - $dir_x * sprite_y);

    //this is actually the depth inside the screen, that what Z is in 3D, the distance of sprite to player, matching sqrt(spriteDistance[i])
    double transform_y = inv_det * (-$plane_y * sprite_x + $plane_x * sprite_y);

    int sprite_screen_x = int(($width / 2) * (1 + transform_x / transform_y));

    // calculate the height of the sprite on screen
    //using "transform_y" instead of the real distance prevents fisheye
    int sprite_height = abs(int($height / transform_y));
    if(sprite_height == 0) continue;

    // calculate width the the sprite
    // same as height of sprite, given that it's square
    int sprite_width = abs(int($height / transform_y));
    if(sprite_width == 0) continue;

    int draw_start_x = -sprite_width / 2 + sprite_screen_x;
    if(draw_start_x < 0) draw_start_x = 0;
    int draw_end_x = sprite_width / 2 + sprite_screen_x;
    if(draw_end_x > $width) draw_end_x = $width;

    int stripe = draw_start_x;
    for(; stripe < draw_end_x; stripe++) {
      //the conditions in the if are:
      //1) it's in front of camera plane so you don't see things behind you
      //2) $zbuffer, with perpendicular distance
      if(!(transform_y > 0 && transform_y < $zbuffer[stripe])) break;
    }

    int tex_x_end = int(texture_width * (stripe - (-sprite_width / 2 + sprite_screen_x)) * texture_width / sprite_width) / texture_width;

    if(draw_start_x < draw_end_x && transform_y > 0 && transform_y < $zbuffer[draw_start_x]) {
      //calculate lowest and highest pixel to fill in current stripe
      int draw_start_y = -sprite_height / 2 + $height / 2;
      if(draw_start_y < 0) draw_start_y = 0;

      int tex_x = int(texture_width * (draw_start_x - (-sprite_width / 2 + sprite_screen_x)) * texture_width / sprite_width) / texture_width;
      int tex_render_width = tex_x_end - tex_x;

      // avoid drawing sprites that are not visible (width < 0)
      if(tex_render_width <= 0) continue;

      float x = float(draw_start_x + $screen_pos_x);
      float y = float(draw_start_y + $screen_pos_y);

      if(x < $screen_pos_x) dbc::log("X < rayview left bounds");
      if(y < $screen_pos_y) dbc::log("Y < rayview top bounds");
      if(x >= SCREEN_WIDTH) dbc::log("OUT OF BOUNDS X");
      if(y >= $height) dbc::log("OUT OF BOUNDS Y");

      float sprite_scale_w = float(sprite_width) / float(texture_width);
      float sprite_scale_h = float(sprite_height) / float(texture_height);

      int d = y * texture_height - $height * half_height + sprite_height * half_height;
      int tex_y = ((d * texture_height) / sprite_height) / texture_height;

      sf::Vector2f origin{texture_width / 2.0f, texture_height / 2.0f};
      sf::Vector2f scale{sprite_scale_w, sprite_scale_h};
      sf::Vector2f position{x + origin.x * scale.x, y + origin.y * scale.y};
      sf::IntRect in_texture{ {tex_x, tex_y}, {tex_render_width, texture_height}};

      if($level.world->has<components::Animation>(rec.second)) {
        auto& animation = $level.world->get<components::Animation>(rec.second);
        if(animation.playing) animation.step(scale, position, in_texture);
      }

      sf_sprite->setOrigin(origin);
      sf_sprite->setScale(scale);
      sf_sprite->setTextureRect(in_texture);
      sf_sprite->setPosition(position);

      // the SpatialMap.distance_sorted only calculates the
      // (x1-x2)^2 + (y1-y2)^2 portion of distance, so to get
      // the actual distance we need to sqrt that.
      // float level = sqrt(rec.first);
      float level = lights[sprite_pos.location.y][sprite_pos.location.x] * PERCENT;

      shared_ptr<sf::Shader> effect = System::sprite_effect($level, rec.second);

      if(effect) {
        apply_sprite_effect(effect, sprite_width, sprite_height);
      } else {
        effect = $brightness;
        level += (aiming_at == sprite_pos.location) * AIMED_AT_BRIGHTNESS;
        effect->setUniform("darkness", level);
      }

      target.draw(*sf_sprite, effect.get());
    }
  }
}

void Raycaster::cast_rays() {
  constexpr static const int texture_width = TEXTURE_WIDTH;
  constexpr static const int texture_height = TEXTURE_HEIGHT;
  double perp_wall_dist;
  auto& lights = $level.lights->lighting();

  // WALL CASTING
  for(int x = 0; x < $width; x++) {
    // calculate ray position and direction
    double cameraX = 2 * x / double($width) - 1; // x-coord in camera space
    double ray_dir_x = $dir_x + $plane_x * cameraX;
    double ray_dir_y = $dir_y + $plane_y * cameraX;

    // which box of the map we're in
    int map_x = int($pos_x);
    int map_y = int($pos_y);

    // length of ray from one x or y-side to next x or y-side
    double delta_dist_x = std::abs(1.0 / ray_dir_x);
    double delta_dist_y = std::abs(1.0 / ray_dir_y);

    int step_x = 0;
    int step_y = 0;
    int hit = 0;
    int side = 0;

    // length of ray from current pos to next x or y-side
    double side_dist_x;
    double side_dist_y;

    if(ray_dir_x < 0) {
      step_x = -1;
      side_dist_x = ($pos_x - map_x) * delta_dist_x;
    } else {
      step_x = 1;
      side_dist_x = (map_x + 1.0 - $pos_x) * delta_dist_x;
    }

    if(ray_dir_y < 0) {
      step_y = -1;
      side_dist_y = ($pos_y - map_y) * delta_dist_y;
    } else {
      step_y = 1;
      side_dist_y = (map_y + 1.0 - $pos_y) * delta_dist_y;
    }

    // perform DDA
    while(hit == 0) {
      if(side_dist_x < side_dist_y) {
        side_dist_x += delta_dist_x;
        map_x += step_x;
        side = 0;
      } else {
        side_dist_y += delta_dist_y;
        map_y += step_y;
        side = 1;
      }

      if($walls[map_y][map_x] == 1) hit = 1;
    }

    if(side == 0) {
      perp_wall_dist = (side_dist_x - delta_dist_x);
    } else {
      perp_wall_dist = (side_dist_y - delta_dist_y);
    }

    int line_height = int($height / perp_wall_dist);

    int draw_start = -line_height / 2 + $height / 2 + $pitch;
    if(draw_start < 0) draw_start = 0;

    int draw_end = line_height / 2 + $height / 2 + $pitch;
    if(draw_end >= $height) draw_end = $height - 1;

    auto texture = textures::get_surface($tiles[map_y][map_x]);

    // calculate value of wall_x
    double wall_x;  // where exactly the wall was hit
    if(side == 0) {
      wall_x = $pos_y + perp_wall_dist * ray_dir_y;
    } else {
      wall_x = $pos_x + perp_wall_dist * ray_dir_x;
    }
    wall_x -= floor(wall_x);

    // x coorindate on the texture
    int tex_x = int(wall_x * double(texture_width));
    if(side == 0 && ray_dir_x > 0) tex_x = texture_width - tex_x - 1;
    if(side == 1 && ray_dir_y < 0) tex_x = texture_width - tex_x - 1;

    // LODE: an integer-only bresenham or DDA like algorithm could make the texture coordinate stepping faster

    // How much to increase the texture coordinate per screen pixel
    double step = 1.0 * texture_height / line_height;
    // Starting texture coordinate
    double tex_pos = (draw_start - $pitch - $height / 2 + line_height / 2) * step;

    for(int y = draw_start; y < draw_end; y++) {
      int tex_y = (int)tex_pos & (texture_height - 1);
      tex_pos += step;
      RGBA pixel = texture[texture_height * tex_y + tex_x];
      int light_level = lights[map_y][map_x];
      $pixels[pixcoord(x, y)] = lighting_calc(pixel, perp_wall_dist, light_level);
    }

    // SET THE ZBUFFER FOR THE SPRITE CASTING
    $zbuffer[x] = perp_wall_dist;
  }
}

void Raycaster::draw_ceiling_floor() {
  constexpr const int texture_width = TEXTURE_WIDTH;
  constexpr const int texture_height = TEXTURE_HEIGHT;

  auto &lights = $level.lights->lighting();
  size_t surface_i = 0;
  const RGBA *floor_texture = textures::get_surface(surface_i);
  const RGBA *ceiling_texture = textures::get_ceiling(surface_i);

  for(int y = $height / 2 + 1; y < $height; ++y) {
    // rayDir for leftmost ray (x=0) and rightmost (x = w)
    float ray_dir_x0 = $dir_x - $plane_x;
    float ray_dir_y0 = $dir_y - $plane_y;
    float ray_dir_x1 = $dir_x + $plane_x;
    float ray_dir_y1 = $dir_y + $plane_y;

    // current y position compared to the horizon
    int p = y - $height / 2;

    // vertical position of the camera
    // 0.5 will the camera at the center horizon. For a
    // different value you need a separate loop for ceiling
    // and floor since they're no longer symmetrical.
    float pos_z = 0.5 * $height;

    // horizontal distance from the camera to the floor for the current row
    // 0.5 is the z position exactly in the middle between floor and ceiling
    // See NOTE in Lode's code for more.
    float row_distance = pos_z / p;

    // calculate the real world step vector we have to add for each x (parallel to camera plane)
    // adding step by step avoids multiplications with a wight in the inner loop
    float floor_step_x = row_distance * (ray_dir_x1 - ray_dir_x0) / $width;
    float floor_step_y = row_distance * (ray_dir_y1 - ray_dir_y0) / $width;

    // real world coordinates of the leftmost column.
    // This will be updated as we step to the right
    float floor_x = $pos_x + row_distance * ray_dir_x0;
    float floor_y = $pos_y + row_distance * ray_dir_y0;

    for(int x = 0; x < $width; ++x) {
      // the cell coord is simply taken from the int parts of
      // floor_x and floor_y.
      int cell_x = int(floor_x);
      int cell_y = int(floor_y);

      // get the texture coordinate from the fractional part
      int tx = int(texture_width * (floor_x - cell_x)) & (texture_width - 1);
      int ty = int(texture_width * (floor_y - cell_y)) & (texture_height - 1);

      floor_x += floor_step_x;
      floor_y += floor_step_y;

      // now get the pixel from the texture
      RGBA color;
      // this uses the previous ty/tx fractional parts of
      // floor_x cell_x to find the texture x/y. How?
      int map_x = int(floor_x);
      int map_y = int(floor_y);

      if(!matrix::inbounds(lights, map_x, map_y)) continue;

      int light_level = lights[map_y][map_x];
      size_t new_surface_i = $tiles[map_y][map_x];

      if(new_surface_i != surface_i) {
        surface_i = new_surface_i;
        floor_texture = textures::get_surface(surface_i);
        ceiling_texture = textures::get_ceiling(surface_i);
      }

      // NOTE: use map_x/y to get the floor, ceiling texture.

      // FLOOR
      color = floor_texture[texture_width * ty + tx];
      $pixels[pixcoord(x, y)] = lighting_calc(color, row_distance, light_level);

      // CEILING
      color = ceiling_texture[texture_width * ty + tx];
      $pixels[pixcoord(x, $height - y - 1)] = lighting_calc(color, row_distance, light_level);
    }
  }
}

void Raycaster::render() {
  draw_ceiling_floor();
  cast_rays();
  draw_pixel_buffer();
}

void Raycaster::draw(sf::RenderTarget& target) {
  target.draw($view_sprite);
  sprite_casting(target);
}

void Raycaster::update_sprite(DinkyECS::Entity ent, components::Sprite& sprite) {
  auto sprite_txt = textures::get_sprite(sprite.name);
  $sprites.insert_or_assign(ent, sprite_txt);
}

void Raycaster::update_level(GameLevel level) {
  $sprites.clear();

  $level = level;

  $tiles = $level.map->tiles();
  $walls = $level.map->walls();

  $level.world->query<components::Sprite>([&](const auto ent, auto& sprite) {
      // player doesn't need a sprite
      if($level.player != ent) {
        update_sprite(ent, sprite);
      }
  });
}

void Raycaster::init_shaders() {
  $brightness = shaders::get("rayview_sprites");
}


Point Raycaster::plan_move(int dir, bool strafe) {
  $camera.t = 0.0;
  if(strafe) {
    $camera.target_x = $pos_x + int(-$dir_y * 1.5 * dir);
    $camera.target_y = $pos_y + int($dir_x * 1.5 * dir);
  } else {
    $camera.target_x = $pos_x + int($dir_x * 1.5 * dir);
    $camera.target_y = $pos_y + int($dir_y * 1.5 * dir);
  }

  return {size_t($camera.target_x), size_t($camera.target_y)};
}

void Raycaster::plan_rotate(int dir, float amount) {
  $camera.t = 0.0;
  double angle_dir = std::numbers::pi * amount * float(dir);

  $camera.target_dir_x = $dir_x * cos(angle_dir) - $dir_y * sin(angle_dir);
  $camera.target_dir_y = $dir_x * sin(angle_dir) + $dir_y * cos(angle_dir);

  $camera.target_plane_x = $plane_x * cos(angle_dir) - $plane_y * sin(angle_dir);
  $camera.target_plane_y = $plane_x * sin(angle_dir) + $plane_y * cos(angle_dir);
}

bool Raycaster::play_rotate() {
  $camera.t += $camera.rot_speed;
  $dir_x = std::lerp($dir_x, $camera.target_dir_x, $camera.t);
  $dir_y = std::lerp($dir_y, $camera.target_dir_y, $camera.t);
  $plane_x = std::lerp($plane_x, $camera.target_plane_x, $camera.t);
  $plane_y = std::lerp($plane_y, $camera.target_plane_y, $camera.t);

  aiming_at = { size_t($pos_x + $dir_x), size_t($pos_y + $dir_y) };

  return $camera.t >= 1.0;
}

bool Raycaster::play_move() {
  $camera.t += $camera.move_speed;
  $pos_x = std::lerp($pos_x, $camera.target_x, $camera.t);
  $pos_y = std::lerp($pos_y, $camera.target_y, $camera.t);

  aiming_at = { size_t($pos_x + $dir_x), size_t($pos_y + $dir_y) };

  return $camera.t >= 1.0;
}

void Raycaster::abort_plan() {
  $camera.target_x = $pos_x;
  $camera.target_y = $pos_y;
  aiming_at = { size_t($pos_x + $dir_x), size_t($pos_y + $dir_y) };
}

bool Raycaster::is_target(DinkyECS::Entity entity) {
  (void)entity;
  return false;
}

Point Raycaster::camera_target() {
  return {
      size_t($camera.target_x),
      size_t($camera.target_y)};
}
