class IMICRTS
  class Pathfinder
    class BasePathfinder
      Node = Struct.new(:tile, :parent, :distance, :cost)
      CACHE = {}

      def self.cached_path(entity, goal, travels_along)
        found_path = CACHE.dig(travels_along, entity, goal)
        if found_path
          found_path = nil unless found_path.valid?
        end

        return found_path
      end

      def self.cache_path(path)
        CACHE[path.travels_along] ||= {}
        CACHE[path.travels_along][path.source] ||= {}
        CACHE[path.travels_along][path.source][path.goal] = path

        return path
      end

      attr_reader :map, :source, :goal, :travels_along, :allow_diagonal
      attr_reader :path, :age
      def initialize(director:, entity:, goal:, travels_along: :ground, allow_diagonal: false)
        @director = director
        @map = @director.map
        @entity = entity
        _goal = goal.clone
        _goal /= @map.tile_size
        _goal.x, _goal.y = _goal.x.floor, _goal.y.floor
        @goal = _goal

        @travels_along = travels_along
        @allow_diagonal = allow_diagonal
        @age = Gosu.milliseconds

        @created_nodes = 0
        @nodes = []
        @path  = []
        @tiles = @map.tiles

        @visited = Hash.new do |hash, value|
          hash[value] = Hash.new {|h, v| h[v] = false}
        end

        @depth = 0
        @max_depth = @map.width * @map.height
        @seeking = true

        @position = entity.position.clone
        @position.x, @position.y = @position.x.floor, @position.y.floor
        @position /= @map.tile_size

        @path_current_node = 0

        @current_node = create_node(@position.x, @position.y, nil, 0)
        unless @current_node
          puts "Unable to find path as position is not a traversable tile for :#{@entity.name}!" if Setting.enabled?(:debug_mode)
          return
        end

        @current_node.distance = 0
        @current_node.cost = 0
        add_node @current_node

        find

        self.class.cache_path(self) if @path.size > 0 && true#Setting.enabled?(:debug_cache_paths)
      end

      def path_current_node
        @path[@path_current_node]
      end

      def path_current_node_index
        @path_current_node
      end

      def path_next_node
        @path_current_node += 1
      end

      def at_current_path_node?(entity)
        if node = path_current_node
          entity.position.distance((node.tile.position + @map.tile_size / 2).xy) <= @entity.radius
        else
          true
        end
      end

      # Checks if Map still has all of paths required tiles
      def valid?
        valid = true
        @path.each do |node|
          unless @map.tiles.dig(node.tile.grid_position.x, node.tile.grid_position.y).entity == node.tile.entity
            valid = false
            break
          end
        end

        return valid
      end

      def find
        while(@seeking && @depth < @max_depth)
          seek
        end

        if @depth >= @max_depth || (@path.size == 0 && @depth < 0)
          puts "Failed to find path from: #{@position.x}:#{@position.y} to: #{@goal.x}:#{@goal.y} [#{@depth}/#{@max_depth} depth]" if Setting.enabled?(:debug_mode)
        end
      end

      def at_goal?
        @current_node.tile.grid_position.distance(@goal) <= 0.9
      end

      def seek
        unless @current_node && @tiles.dig(@goal.x, @goal.y)
          @seeking = false
          return
        end

        @nodes.delete(@current_node) # delete visited nodes
        @visited[@current_node.tile.grid_position.x][@current_node.tile.grid_position.y] = true

        if at_goal?
          until(@current_node.parent.nil?)
            @path << @current_node
            @current_node = @current_node.parent
          end
          @path.reverse!

          @seeking = false
          puts "Generated path with #{@path.size} steps, #{@created_nodes} nodes created. [#{@depth}/#{@max_depth} depth]" if Setting.enabled?(:debug_mode)
          return
        end

        #LEFT
        add_node create_node(@current_node.tile.grid_position.x - 1, @current_node.tile.grid_position.y, @current_node)
        # RIGHT
        add_node create_node(@current_node.tile.grid_position.x + 1, @current_node.tile.grid_position.y, @current_node)
        # UP
        add_node create_node(@current_node.tile.grid_position.x, @current_node.tile.grid_position.y - 1, @current_node)
        # DOWN
        add_node create_node(@current_node.tile.grid_position.x, @current_node.tile.grid_position.y + 1, @current_node)

        if @allow_diagonal
          # LEFT-UP
          if node_above? && node_above_left?
            add_node create_node(@current_node.tile.grid_position.x - 1, @current_node.tile.grid_position.y - 1, @current_node, 1.4)
          end
          # LEFT-DOWN
          if node_below? && node_below_left?
            add_node create_node(@current_node.tile.grid_position.x - 1, @current_node.tile.grid_position.y + 1, @current_node, 1.4)
          end
          # RIGHT-UP
          if node_above? && node_above_right?
            add_node create_node(@current_node.tile.grid_position.x + 1, @current_node.tile.grid_position.y - 1, @current_node, 1.4)
          end
          # RIGHT-DOWN
          if node_below? && node_below_right?
            add_node create_node(@current_node.tile.grid_position.x + 1, @current_node.tile.grid_position.y + 1, @current_node, 1.4)
          end
        end

        @current_node = next_node
        @depth += 1
      end

      def node_visited?(node)
        @visited[node.tile.grid_position.x][node.tile.grid_position.y]
      end

      def add_node(node)
        return unless node

        @nodes << node
        return node
      end

      def create_node(x, y, parent, cost = 1.0)
        return unless tile = @tiles.dig(x, y)
        return unless tile.type == @travels_along
        return if tile.entity
        return if @visited.dig(x, y)
        return if @nodes.detect {|node| node.tile.grid_position.x == x && node.tile.grid_position.y == y}

        node = Node.new
        node.tile = tile
        node.parent = parent
        node.distance = parent.distance + 1 if parent
        node.cost = 0

        @created_nodes += 1
        return node
      end

      def next_node
        fittest = nil
        fittest_distance = Float::INFINITY
        fittest_cost = Float::INFINITY

        distance = nil
        @nodes.each do |node|
          next if node == @current_node

          distance = node.tile.grid_position.distance(@goal)
          cost = node.cost

          if distance + cost < fittest_distance + fittest_cost
            fittest = node
            fittest_distance = distance
            fittest_cost = cost
          end
        end

        return fittest
      end

      def node_above?(node = @current_node)
        node && @tiles.dig(node.tile.grid_position.x, node.tile.grid_position.y - 1)
      end

      def node_below?(node = @current_node)
        node && @tiles.dig(node.tile.grid_position.x, node.tile.grid_position.y + 1)
      end

      def node_above_left?(node = @current_node)
        node && @tiles.dig(node.tile.grid_position.x - 1, node.tile.grid_position.y - 1)
      end

      def node_above_right?(node = @current_node)
        node && @tiles.dig(node.tile.grid_position.x + 1, node.tile.grid_position.y - 1)
      end

      def node_below_left?(node = @current_node)
        node && @tiles.dig(node.tile.grid_position.x - 1, node.tile.grid_position.y + 1)
      end

      def node_below_right?(node = @current_node)
        node && @tiles.dig(node.tile.grid_position.x + 1, node.tile.grid_position.y + 1)
      end
    end
  end
end