#include <SDL.h>
#include <stdexcept>
#include <memory>
#include <vector>
#include <list>
#include <random>
#include <fov.h>
#include <iostream>
#include <tuple>
#include <set>
#include "util.h"
#include "map.h"

class sdl_error : public std::logic_error {
public:

  sdl_error() : std::logic_error(SDL_GetError())
  {
  }
};

class window_deleter {
public:

  void operator()(SDL_Window* ptr)
  {
    SDL_DestroyWindow(ptr);
  }
};

using window_ptr = std::unique_ptr<SDL_Window, window_deleter>;

class renderer_deleter {
public:

  void operator()(SDL_Renderer* ptr)
  {
    SDL_DestroyRenderer(ptr);
  }
};

using renderer_ptr = std::unique_ptr<SDL_Renderer, renderer_deleter>;

class texture_deleter {
public:

  void operator()(SDL_Texture* ptr)
  {
    SDL_DestroyTexture(ptr);
  }
};

using texture_ptr = std::unique_ptr<SDL_Texture, texture_deleter>;

enum class Tile {
  Floor,
  Wall,
  Dust,
  Lamp
};

enum class LoseState {
  None,
  PoppingLamps,
  PoppingPlayer,
  Outro
};

const int GAME_WIDTH = 640*2;
const int GAME_HEIGHT = 480*2;
const int TILE_WIDTH = 8*2;
const int TILE_HEIGHT = TILE_WIDTH;
const int INIT_ZOOM = 10;
const int ZOOM_X_FACTOR = 8;
const int ZOOM_Y_FACTOR = 6;
const int RADIUS = 8;

struct Input {
  bool left = false;
  bool right = false;
  bool up = false;
  bool down = false;
};

using coord = std::tuple<int, int>;

struct Kickup {
  int x;
  int y;
  size_t cur;
  size_t radius;
  size_t chain;
  std::set<coord> done;
  std::set<coord> front;
};

struct MapData {
  Tile tile = Tile::Floor;
  bool lit = false;
  bool wasLit = false;
  double visibility = 0.0;
  size_t dustLife = 0;
};

class Game {
public:

  Game(std::mt19937& rng) :
    rng(rng),
    map(
      -INIT_ZOOM * ZOOM_X_FACTOR / 2,
      -INIT_ZOOM * ZOOM_Y_FACTOR / 2,
      INIT_ZOOM * ZOOM_X_FACTOR,
      INIT_ZOOM * ZOOM_Y_FACTOR)
  {
  }

  std::mt19937& rng;

  Map<MapData> map;
  std::list<Kickup> kickups;
  int litSpots = 0;
  bool dirtyLighting = true;
  size_t numLamps = 0;
  size_t numDust = 0;

  int player_x = 0;
  int player_y = 0;
  bool renderPlayer = true;

  int curZoom = INIT_ZOOM;
  int maxZoom = INIT_ZOOM;

  bool zooming = false;
  //size_t oldZoom;
  int zoomProgress = 0;

};

void render(
  SDL_Renderer* ren,
  const Game& game,
  bool drawDark = true)
{
  texture_ptr canvas(
    SDL_CreateTexture(
      ren,
      SDL_PIXELFORMAT_RGBA8888,
      SDL_TEXTUREACCESS_TARGET,
      TILE_WIDTH * game.map.getWidth(),
      TILE_HEIGHT * game.map.getHeight()));

  if (!canvas)
  {
    throw sdl_error();
  }

  SDL_SetRenderTarget(ren, canvas.get());

  SDL_SetRenderDrawColor(ren, rand() % 255, rand() % 255, rand() % 255, 255);
  SDL_RenderClear(ren);

  for (int y = game.map.getTop(); y < game.map.getBottom(); y++)
  {
    for (int x = game.map.getLeft(); x < game.map.getRight(); x++)
    {
      bool draw = true;

      if ((game.player_x == x && game.player_y == y) && game.renderPlayer)
      {
        SDL_SetRenderDrawColor(ren, 255, 255, 0, 255);
      } else if (!game.map.at(x,y).lit)
      {
        if (drawDark)
        {
          SDL_SetRenderDrawColor(ren, 40, 40, 40, 255);
        } else {
          draw = false;
        }
      } else {
        switch (game.map.at(x,y).tile)
        {
          case Tile::Floor:
          {
            SDL_SetRenderDrawColor(ren, 210, 210, 210, 255);
            break;
          }

          case Tile::Wall:
          {
            SDL_SetRenderDrawColor(ren, 100, 100, 100, 255);
            break;
          }

          case Tile::Dust:
          {
            SDL_SetRenderDrawColor(ren, 128, 40, 255, 255);
            break;
          }

          case Tile::Lamp:
          {
            SDL_SetRenderDrawColor(ren, 0, 255, 255, 255);
            break;
          }
        }
      }

      if (draw)
      {
        SDL_Rect rect {
          game.map.getTrueX(x) * TILE_WIDTH,
          game.map.getTrueY(y) * TILE_HEIGHT,
          TILE_WIDTH,
          TILE_HEIGHT};

        SDL_RenderFillRect(ren, &rect);

        int alpha = (1.0 - game.map.at(x,y).visibility) * 255;
        SDL_SetRenderDrawColor(ren, 40, 40, 40, alpha);
        SDL_RenderFillRect(ren, &rect);
      }
    }
  }

  SDL_SetRenderTarget(ren, nullptr);

  if (!game.zooming)
  {
    SDL_RenderCopy(ren, canvas.get(), nullptr, nullptr);
  } else {
    // TODO: zooming back in to the player
    SDL_Rect zoomRect {
      ((game.maxZoom - game.curZoom) * TILE_WIDTH + game.zoomProgress)
        * ZOOM_X_FACTOR / 2,
      ((game.maxZoom - game.curZoom) * TILE_HEIGHT + game.zoomProgress)
        * ZOOM_Y_FACTOR / 2,
      (game.curZoom * TILE_WIDTH - game.zoomProgress) * ZOOM_X_FACTOR,
      (game.curZoom * TILE_HEIGHT - game.zoomProgress) * ZOOM_Y_FACTOR
    };

    SDL_RenderCopy(ren, canvas.get(), &zoomRect, nullptr);
  }

  SDL_RenderPresent(ren);
}

void incrementIfSet(Game& game, int& count, int x, int y, Tile val = Tile::Wall)
{
  if (game.map.inBounds(x, y) && game.map.at(x,y).tile == val)
  {
    count++;
  }
}

void tick(
  Game& game,
  int x1,
  int y1,
  int x2,
  int y2,
  bool invert = false,
  bool onlyDark = false)
{
  Map<MapData> temp(game.map);

  for (int y = game.map.getTop(); y < game.map.getBottom(); y++)
  {
    for (int x = game.map.getLeft(); x < game.map.getRight(); x++)
    {
      if (invert == (x >= x1 && x < x2 && y >= y1 && y < y2))
      {
        continue;
      }

      if (onlyDark && game.map.at(x,y).lit)
      {
        continue;
      }

      if (game.map.at(x,y).tile == Tile::Lamp)
      {
        continue;
      }

      int count = 0;

      incrementIfSet(game, count, x-1, y-1);
      incrementIfSet(game, count, x-1, y  );
      incrementIfSet(game, count, x-1, y+1);
      incrementIfSet(game, count, x  , y-1);
      incrementIfSet(game, count, x  , y  );
      incrementIfSet(game, count, x  , y+1);
      incrementIfSet(game, count, x+1, y-1);
      incrementIfSet(game, count, x+1, y  );
      incrementIfSet(game, count, x+1, y+1);

      if (count >= 5)
      {
        temp.at(x,y).tile = Tile::Wall;
      } else {
        temp.at(x,y).tile = Tile::Floor;
      }
    }
  }

  game.map = std::move(temp);
}

void tick(Game& game, bool onlyDark = false)
{
  tick(
    game,
    game.map.getLeft(),
    game.map.getTop(),
    game.map.getRight(),
    game.map.getBottom(),
    false,
    onlyDark);
}

void movePlayer(Game& game, int x, int y)
{
  if (game.map.inBounds(x, y) && game.map.at(x,y).tile == Tile::Floor)
  {
    if (game.map.at(game.player_x, game.player_y).tile == Tile::Floor)
    {
      game.map.at(game.player_x, game.player_y).tile = Tile::Dust;
      game.map.at(game.player_x, game.player_y).dustLife = 1;
      game.numDust++;
    }

    game.player_x = x;
    game.player_y = y;

    game.dirtyLighting = true;
  }
}

void recalculateLighting(Game& game, fov_settings_type* fov)
{
  game.litSpots = 0;

  for (MapData& md : game.map.data())
  {
    md.wasLit = md.lit;
    md.lit = false;
    md.visibility = 0.0;
  }

  fov_settings_set_opacity_test_function(
    fov,
    [] (void* data, int x, int y) {
      Game& game = *static_cast<Game*>(data);

      return game.map.inBounds(x,y) && game.map.at(x,y).tile == Tile::Wall;
    });

  fov_settings_set_apply_lighting_function(
    fov,
    [] (void* data, int x, int y, int dx, int dy, void*) {
      Game& game = *static_cast<Game*>(data);

      if (game.map.inBounds(x, y))
      {
        if (!game.map.at(x,y).lit)
        {
          game.litSpots++;
        }

        game.map.at(x,y).lit = true;

        game.map.at(x,y).visibility = std::max(
          game.map.at(x,y).visibility,
          std::pow(
            std::max(
              0.0,
              1.0 - std::sqrt(dx * dx + dy * dy) / static_cast<double>(RADIUS)),
            1.0/3.0));
      }
    });

  for (int y = game.map.getTop(); y < game.map.getBottom(); y++)
  {
    for (int x = game.map.getLeft(); x < game.map.getRight(); x++)
    {
      if ((game.player_x == x && game.player_y == y && game.renderPlayer) ||
          game.map.at(x,y).tile == Tile::Dust ||
          game.map.at(x,y).tile == Tile::Lamp)
      {
        fov_circle(fov, static_cast<void*>(&game), nullptr, x, y, RADIUS);

        game.map.at(x,y).lit = true;
        game.map.at(x,y).visibility = 1.0;
      }
    }
  }

  game.dirtyLighting = false;
}

void processKeys(Game& game, const Input& keystate)
{
  int px = game.player_x;
  int py = game.player_y;

  if (keystate.up)
  {
    py--;
  }

  if (keystate.down)
  {
    py++;
  }

  if (keystate.left)
  {
    px--;
  }

  if (keystate.right)
  {
    px++;
  }

  if (!(game.player_x == px && game.player_y == py))
  {
    movePlayer(game, px, py);
  }
}

void kickUpDust(Game& game, int x, int y, size_t chain)
{
  Kickup dk;
  dk.x = x;
  dk.y = y;
  dk.chain = chain;
  dk.cur = 0;
  dk.radius = RADIUS + (chain + 1) * (chain + 1);
  dk.front.emplace(x, y);
  dk.done.emplace(x, y);

  game.kickups.push_back(dk);
}

void popLamp(Game& game, int x, int y, size_t chain)
{
  if (game.map.at(x,y).tile == Tile::Lamp)
  {
    game.numLamps--;
  }

  game.map.at(x,y).tile = Tile::Dust;
  game.map.at(x,y).dustLife = 2;
  game.numDust++;
  game.dirtyLighting = true;

  kickUpDust(game, x, y, chain);
}

void processKickup(Game& game)
{
  for (Kickup& kickup : game.kickups)
  {
    kickup.cur++;

    std::set<coord> newFront;
    for (const coord& xy : kickup.front)
    {
      auto processDir = [&] (int x, int y) {
        coord c {x,y};

        if (game.map.inBounds(x,y) &&
            (game.map.at(x,y).tile == Tile::Floor ||
              game.map.at(x,y).tile == Tile::Lamp) &&
            !kickup.done.count(c))
        {
          newFront.insert(c);
          kickup.done.insert(c);

          if (game.map.at(x,y).tile == Tile::Floor)
          {
            game.map.at(x,y).tile = Tile::Dust;
            game.map.at(x,y).dustLife = 2;
            game.numDust++;
            game.dirtyLighting = true;
          } else if (game.map.at(x,y).tile == Tile::Lamp)
          {
            popLamp(game, x, y, kickup.chain + 1);
          }
        }
      };

      processDir(std::get<0>(xy) - 1, std::get<1>(xy)    );
      processDir(std::get<0>(xy) + 1, std::get<1>(xy)    );
      processDir(std::get<0>(xy)    , std::get<1>(xy) - 1);
      processDir(std::get<0>(xy)    , std::get<1>(xy) + 1);

      if (std::bernoulli_distribution(0.5)(game.rng))
      {
        processDir(std::get<0>(xy) - 1, std::get<1>(xy) - 1);
      }

      if (std::bernoulli_distribution(0.5)(game.rng))
      {
        processDir(std::get<0>(xy) - 1, std::get<1>(xy) + 1);
      }

      if (std::bernoulli_distribution(0.5)(game.rng))
      {
        processDir(std::get<0>(xy) + 1, std::get<1>(xy) - 1);
      }

      if (std::bernoulli_distribution(0.5)(game.rng))
      {
        processDir(std::get<0>(xy) + 1, std::get<1>(xy) + 1);
      }
    }

    kickup.front.swap(newFront);
  }

  erase_if(
    game.kickups,
    [] (const Kickup& kickup) {
      return kickup.cur == kickup.radius;
    });
}

void growMap(Game& game, size_t zoom)
{
  int ol = game.map.getLeft();
  int ot = game.map.getTop();
  int ow = game.map.getWidth();
  int oh = game.map.getHeight();

  game.map.resize(
    -zoom * ZOOM_X_FACTOR / 2,
    -zoom * ZOOM_Y_FACTOR / 2,
    zoom * ZOOM_X_FACTOR,
    zoom * ZOOM_Y_FACTOR);

  game.maxZoom = zoom;

  for (int y = game.map.getTop(); y < game.map.getBottom(); y++)
  {
    for (int x = game.map.getLeft(); x < game.map.getRight(); x++)
    {
      if (!(x >= ol && x < (ol + ow) && y >= ot && y < (ot + oh)))
      {
        if (std::bernoulli_distribution(0.5)(game.rng))
        {
          game.map.at(x,y).tile = Tile::Wall;
        }
      }
    }
  }

  for (int i = 0; i < 3; i++)
  {
    tick(game, ol, ot, ol + ow, ot + oh, true);
  }
}

void setZoom(Game& game, size_t zoom)
{
  if (zoom == game.curZoom)
  {
    return;
  }

  if (zoom > game.maxZoom)
  {
    growMap(game, zoom);
  }

  // TODO: don't think this works well with rapid zoom changes
  game.zoomProgress += (zoom - game.curZoom) * TILE_WIDTH;
  //game.oldZoom = game.curZoom;
  game.curZoom = zoom;
  game.zooming = true;
}

int main(int, char**)
{
  std::random_device randomEngine;
  std::mt19937 rng(randomEngine());

  if (SDL_Init(SDL_INIT_VIDEO) != 0)
  {
  	throw sdl_error();
  }

  try
  {
    window_ptr win(
      SDL_CreateWindow("Ether", 100, 100, GAME_WIDTH, GAME_HEIGHT, SDL_WINDOW_SHOWN));

    if (!win)
    {
      throw sdl_error();
    }

    renderer_ptr ren(
      SDL_CreateRenderer(
        win.get(),
        -1,
        SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC));

    if (!ren)
    {
      throw sdl_error();
    }

    SDL_SetRenderDrawBlendMode(ren.get(), SDL_BLENDMODE_BLEND);

    Game game(rng);

    std::unique_ptr<fov_settings_type> fov(new fov_settings_type());
    fov_settings_init(fov.get());

    for (MapData& md : game.map.data())
    {
      if (std::bernoulli_distribution(0.5)(rng))
      {
        md.tile = Tile::Wall;
      }
    }

    tick(game);
    tick(game);

    for (int y = -1; y <= 1; y++)
    {
      for (int x = -1; x <= 1; x++)
      {
        game.map.at(x,y).tile = Tile::Floor;
      }
    }

    tick(game);

    bool quit = false;
    LoseState losing = LoseState::None;
    Input keystate;
    SDL_Event e;

    size_t dustDt = 30;
    size_t dustAcc = 0;

    size_t inputDt = 50;
    size_t inputAcc = 0;

    size_t losePopLampDt = 800;
    size_t losePopLampAcc = losePopLampDt;

    size_t losePopPlayerDt = 3000;
    size_t losePopPlayerAcc = 0;

    size_t zoomDt = 62;
    size_t zoomAcc = 0;

    size_t lastTime = SDL_GetTicks();

    while (!quit)
    {
      size_t currentTime = SDL_GetTicks();
      size_t frameTime = currentTime - lastTime;
      lastTime = currentTime;

      while (SDL_PollEvent(&e))
      {
        if (e.type == SDL_QUIT)
        {
          if (losing != LoseState::None)
          {
            quit = true;
          } else {
            losing = LoseState::PoppingLamps;
          }
        } else if (e.type == SDL_KEYDOWN)
        {
          switch (e.key.keysym.sym)
          {
            case SDLK_ESCAPE:
            {
              if (losing != LoseState::None)
              {
                quit = true;
              } else {
                losing = LoseState::PoppingLamps;
              }

              break;
            }

            case SDLK_SPACE:
            {
              if (losing == LoseState::None)
              {
                if (game.map.at(game.player_x, game.player_y).tile ==
                      Tile::Floor)
                {
                  game.map.at(game.player_x, game.player_y).tile = Tile::Lamp;
                  game.numLamps++;
                  game.dirtyLighting = true;
                  kickUpDust(game, game.player_x, game.player_y, 0);

                  for (int i = 0; i < 5; i++)
                  {
                    processKeys(game, keystate);

                    tick(
                      game,
                      game.player_x - (RADIUS - 1),
                      game.player_y - (RADIUS - 1),
                      game.player_x + RADIUS,
                      game.player_y + RADIUS);
                  }
                }
              }

              break;
            }
          }
        }
      }

      const Uint8* state = SDL_GetKeyboardState(NULL);
      keystate.left = state[SDL_SCANCODE_LEFT];
      keystate.right = state[SDL_SCANCODE_RIGHT];
      keystate.up = state[SDL_SCANCODE_UP];
      keystate.down = state[SDL_SCANCODE_DOWN];

      dustAcc += frameTime;
      inputAcc += frameTime;

      while (dustAcc >= dustDt)
      {
        game.numDust = 0;

        for (MapData& md : game.map.data())
        {
          if (md.tile == Tile::Dust)
          {
            md.dustLife--;

            if (md.dustLife <= 0)
            {
              md.tile = Tile::Floor;
              game.dirtyLighting = true;
            } else {
              game.numDust++;
            }
          }
        }

        processKickup(game);

        dustAcc -= dustDt;
      }

      switch (losing)
      {
        case LoseState::None:
        {
          while (inputAcc >= inputDt)
          {
            processKeys(game, keystate);

            inputAcc -= inputDt;
          }

          break;
        }

        case LoseState::PoppingLamps:
        {
          if (game.numLamps == 0)
          {
            if (game.numDust == 0)
            {
              losing = LoseState::PoppingPlayer;
            }
          } else {
            losePopLampAcc += frameTime;

            while (losePopLampAcc >= losePopLampDt)
            {
              std::vector<std::tuple<int, int>> lamps;

              for (int y = game.map.getTop(); y < game.map.getBottom(); y++)
              {
                for (int x = game.map.getLeft(); x < game.map.getRight(); x++)
                {
                  if (game.map.at(x,y).tile == Tile::Lamp)
                  {
                    lamps.emplace_back(x, y);
                  }
                }
              }

              std::uniform_int_distribution<int> lampDist(0, lamps.size() - 1);
              std::tuple<int, int> popPos = lamps[lampDist(rng)];

              popLamp(game, std::get<0>(popPos), std::get<1>(popPos), 1);

              losePopLampAcc -= losePopLampDt;
            }
          }

          break;
        }

        case LoseState::PoppingPlayer:
        {
          losePopPlayerAcc += frameTime;

          if (losePopPlayerAcc >= losePopPlayerDt)
          {
            popLamp(game, game.player_x, game.player_y, 10);
            game.renderPlayer = false;

            losing = LoseState::Outro;
          }

          break;
        }

        case LoseState::Outro:
        {
          if (game.numDust == 0)
          {
            quit = true;
          }

          break;
        }
      }

      if (game.dirtyLighting)
      {
        recalculateLighting(game, fov.get());

        for (int y = game.map.getTop(); y < game.map.getBottom(); y++)
        {
          for (int x = game.map.getLeft(); x < game.map.getRight(); x++)
          {
            if (!game.map.at(x,y).lit && game.map.at(x,y).wasLit)
            {
              if (std::bernoulli_distribution(0.5)(rng))
              {
                game.map.at(x,y).tile = Tile::Wall;
              } else {
                game.map.at(x,y).tile = Tile::Floor;
              }
            }
          }
        }

        tick(game, true);
        tick(game, true);
        tick(game, true);

        // TODO: better zoom algorithm
        setZoom(game, game.litSpots / 1500 * 2 + INIT_ZOOM);
      }

      zoomAcc += frameTime;

      while (zoomAcc >= zoomDt)
      {
        if (game.zooming)
        {
          if (game.zoomProgress > 0)
          {
            game.zoomProgress--;
          } else if (game.zoomProgress < 0)
          {
            game.zoomProgress++;
          }

          if (game.zoomProgress == 0)
          {
            game.zooming = false;
          }
        }

        zoomAcc -= zoomDt;
      }

      render(ren.get(), game, true);
    }
  } catch (const sdl_error& ex)
  {
    std::cout << "SDL error (" << ex.what() << ")" << std::endl;
  }

  SDL_Quit();

  return 0;
}