# ###################################################
# Copyright (C) 2008-2017 The Unknown Horizons Team
# team@unknown-horizons.org
# This file is part of Unknown Horizons.
#
# Unknown Horizons is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the
# Free Software Foundation, Inc.,
# 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
# ###################################################

import itertools
from collections import ChainMap

from horizons.constants import BUILDINGS
from horizons.entities import Entities
from horizons.i18n import gettext_lazy as LazyT
from horizons.util.pathfinding.pathfinder import a_star_find_path
from horizons.util.shapes import Circle, Point, Rect
from horizons.util.worldobject import WorldObject
from horizons.world.buildability.terraincache import TerrainRequirement


class BuildableErrorTypes:
	"""Killjoy class. Collection of reasons why you can't build."""
	NO_ISLAND, UNFIT_TILE, NO_SETTLEMENT, OTHER_PLAYERS_SETTLEMENT, \
	OTHER_PLAYERS_SETTLEMENT_ON_ISLAND, OTHER_BUILDING_THERE, UNIT_THERE, NO_COAST, \
	NO_OCEAN_NEARBY, ONLY_NEAR_SHIP, NEED_RES_SOURCE, ISLAND_ALREADY_SETTLED, \
	NO_FLAT_LAND = range(13)

	text = {
	  NO_ISLAND: LazyT("This building must be built on an island."),
	  UNFIT_TILE: LazyT("This ground is not suitable for this building."),
	  NO_SETTLEMENT: LazyT("This building has to be built within your settlement."),
	  OTHER_PLAYERS_SETTLEMENT: LazyT("This area is already occupied by another player."),
	  OTHER_BUILDING_THERE: LazyT("This area is already occupied by another building."),
	  UNIT_THERE: LazyT("This area is already occupied by a unit."),
	  NO_COAST: LazyT("This building must be built on the coastline."),
	  NO_OCEAN_NEARBY: LazyT("This building has to be placed at the ocean."),
	  ONLY_NEAR_SHIP: LazyT("This spot is too far away from your ship."),
	  NEED_RES_SOURCE: LazyT("This building can only be built on a resource source."),
	  ISLAND_ALREADY_SETTLED: LazyT("You have already settled this island."),
	  NO_FLAT_LAND: LazyT("This building must be partly on flat land.")
	}
	# TODO: say res source which one we need, maybe even highlight those


class _BuildPosition:
	"""A possible build position in form of a data structure.
	Don't use directly outside of this file"""
	def __init__(self, position, rotation, tearset, buildable, action='idle',
	             problem=None):
		"""
		@param position: Rect, building position and size
		@param rotation: int rotation of building
		@param tearset: list of worldids of buildings to tear for this building to build
		@param buildable: whether building is actually buildable there
		@param action: action (animation of building)
		@param problem: (error number, error string) reason it's not buildable or None
		"""
		self.position = position
		self.rotation = rotation
		self.tearset = tearset
		self.buildable = buildable
		self.action = action
		self.problem = problem

	def __bool__(self):
		"""Returns buildable value. This enables code such as "if cls.check_build()"""
		return self.buildable

	def __eq__(self, other):
		if not isinstance(other, _BuildPosition):
			return False
		return (self.position == other.position and
		        self.rotation == other.rotation and
		        self.action == other.action and
		        self.tearset == other.tearset)

	def __ne__(self, other):
		return not self.__eq__(other)

	def __hash__(self):
		return hash((self.position, self.rotation, self.action))


class _NotBuildableError(Exception):
	"""Internal exception."""
	def __init__(self, errortype):
		super().__init__()
		self.errortype = errortype


class Buildable:
	"""Interface for every kind of buildable objects.
	Contains methods to determine whether a building can be placed on a coordinate, regarding
	island, settlement, ground requirements etc. Does not care about building costs."""

	irregular_conditions = False # whether all ground tiles have to fulfill the same conditions
	terrain_type = TerrainRequirement.LAND

	# check this far for fuzzy build
	CHECK_NEARBY_LOCATIONS_UP_TO_DISTANCE = 3

	# INTERFACE

	@classmethod
	def check_build(cls, session, point, rotation=45, check_settlement=True, ship=None, issuer=None):
		"""Check if a building is buildable here.
		All tiles, that the building occupies are checked.
		@param point: Point instance, coords
		@param rotation: preferred rotation of building
		@param check_settlement: whether to check for a settlement (for settlementless buildings)
		@param ship: ship instance if building from ship
		@return instance of _BuildPosition"""
		# for non-quadratic buildings, we have to switch width and height depending on the rotation
		if rotation in [45, 225]:
			position = Rect.init_from_topleft_and_size(point.x, point.y, cls.size[0], cls.size[1])
		else:
			position = Rect.init_from_topleft_and_size(point.x, point.y, cls.size[1], cls.size[0])

		buildable = True
		problem = None
		tearset = []
		try:
			island = cls._check_island(session, position)
			# TODO: if the rotation changes here for non-quadratic buildings, wrong results will be returned
			rotation = cls._check_rotation(session, position, rotation)
			tearset = cls._check_buildings(session, position, island=island)
			cls._check_units(session, position)
			if check_settlement:
				cls._check_settlement(session, position, ship=ship, issuer=issuer)
		except _NotBuildableError as e:
			buildable = False
			problem = (e.errortype, BuildableErrorTypes.text[e.errortype])

		return _BuildPosition(position, rotation, tearset, buildable, problem=problem)

	@classmethod
	def check_build_line(cls, session, point1, point2, rotation=45, ship=None):
		"""Checks out a line on the map for build possibilities.
		The line usually is a draw of the mouse.
		@param point1, point2: Point instance, start and end of the line
		@param rotation: prefered rotation
		@param ship: ship instance if building from ship
		@return list of _BuildPositions
		"""
		raise NotImplementedError

	@classmethod
	def is_tile_buildable(cls, session, tile, ship, island=None, check_settlement=True):
		"""Checks a tile for buildability.
		@param tile: Ground object
		@param ship: Ship instance if building from ship
		@param island: Island instance, if already known. If None, it will be calculated
		@param check_settlement: bool, whether to check for settlement
		@return bool, True for "is buildable" """
		position = Point(tile.x, tile.y)
		try:
			cls._check_island(session, position, island)
			if check_settlement:
				cls._check_settlement(session, position, ship=ship)
			cls._check_buildings(session, position)
		except _NotBuildableError:
			return False

		if cls.irregular_conditions:
			# check in case not all ground tiles have to fulfill the same conditions (e.g. when 1 tile has to be coast)

			# at least one location that has this tile must be actually buildable
			# area of the buildings is (x, y) + width/height, therefore all build positions that
			# include (x, y) are (x, y) - ( [0..width], [0..height] )
			return any(cls.check_build(session, Point(tile.x - x_off, tile.y - y_off), ship=ship)
			           for x_off, y_off in itertools.product(range(cls.size[0]), range(cls.size[1])))
		else:
			return True

	@classmethod
	def check_build_fuzzy(cls, session, point, *args, **kwargs):
		"""Same as check_build, but consider point to be a vague suggestions
		and search nearby area for buildable position.
		Returns one of the closest viable positions or the original position as not buildable if none can be found"""

		# this is some kind of case study of applied functional programming

		def filter_duplicates(gen, transform=lambda x: x):
			"""
			@param transform: transforms elements to hashable equivalent
			"""
			checked = set()
			for elem in itertools.filterfalse(lambda e: transform(e) in checked, gen):
				checked.add(transform(elem))
				yield elem

		# generate coords near point, search coords of small circles to larger ones
		def get_positions():
			iters = (iter(Circle(point, radius)) for radius in range(cls.CHECK_NEARBY_LOCATIONS_UP_TO_DISTANCE))
			return itertools.chain.from_iterable(iters)

		# generate positions and check for matches
		check_pos = lambda pos: cls.check_build(session, pos, *args, **kwargs)
		checked = map(check_pos,
		              filter_duplicates(get_positions(), transform=lambda p: p.to_tuple()))

		# filter positive solutions
		result_generator = filter(lambda buildpos: buildpos.buildable, checked)

		try:
			# return first match
			return next(result_generator)
		except StopIteration:
			# No match found, fail with specified parameters.
			return check_pos(point)

	# PRIVATE PARTS

	@classmethod
	def _check_island(cls, session, position, island=None):
		"""Check if there is an island and enough tiles.
		@throws _NotBuildableError if building can't be built.
		@param position: coord Point to build at
		@param island: Island instance if known before"""
		if island is None:
			if position.__class__ is Rect: # performance optimization
				at = position.left, position.top
			else:
				at = position.center.to_tuple()
			island = session.world.get_island_tuple(at)
			if island is None:
				raise _NotBuildableError(BuildableErrorTypes.NO_ISLAND)
		for tup in position.tuple_iter():
			# can't use get_tile_tuples since it discards None's
			tile = island.get_tile_tuple(tup)
			if tile is None:
				raise _NotBuildableError(BuildableErrorTypes.NO_ISLAND)
			if 'constructible' not in tile.classes:
				raise _NotBuildableError(BuildableErrorTypes.UNFIT_TILE)
		return island

	@classmethod
	def _check_rotation(cls, session, position, rotation):
		"""Returns a possible rotation for this building.
		@param position: Rect or Point instance, position and size
		@param rotation: The preferred rotation
		@return: integer, an available rotation in degrees"""
		return rotation

	@classmethod
	def _check_settlement(cls, session, position, ship=None, issuer=None):
		"""Check that there is a settlement that belongs to the player."""
		player = issuer if issuer is not None else session.world.player
		first_legal_settlement = None
		for coords in position.tuple_iter():
			if coords not in session.world.full_map:
				raise _NotBuildableError(BuildableErrorTypes.NO_SETTLEMENT)
			settlement = session.world.full_map[coords].settlement
			if settlement is None:
				raise _NotBuildableError(BuildableErrorTypes.NO_SETTLEMENT)
			if player != settlement.owner:
				raise _NotBuildableError(BuildableErrorTypes.OTHER_PLAYERS_SETTLEMENT)
			# there should be exactly one legal settlement under the position
			assert first_legal_settlement is None or first_legal_settlement is settlement
			first_legal_settlement = settlement
		assert first_legal_settlement

	@classmethod
	def _check_buildings(cls, session, position, island=None):
		"""Check if there are buildings blocking the build.
		@return Iterable of worldids of buildings that need to be teared in order to build here"""
		if island is None:
			island = session.world.get_island(position.center)
			# _check_island already confirmed that there must be an island here, so no check for None again
		tearset = set()
		for tile in island.get_tiles_tuple(position.tuple_iter()):
			obj = tile.object
			if obj is not None: # tile contains an object
				if obj.buildable_upon:
					if obj.__class__ is cls:
						# don't tear trees to build trees over them
						raise _NotBuildableError(BuildableErrorTypes.OTHER_BUILDING_THERE)
					# tear it so we can build over it
					tearset.add(obj.worldid)
				else:
					# building is blocking the build
					raise _NotBuildableError(BuildableErrorTypes.OTHER_BUILDING_THERE)
		if hasattr(session.manager, 'get_builds_in_construction'):
			builds_in_construction = session.manager.get_builds_in_construction()
			for build in builds_in_construction:
				(sizex, sizey) = Entities.buildings[build.building_class].size
				for (neededx, neededy) in position.tuple_iter():
					if neededx in range(build.x, build.x + sizex) and neededy in range(build.y, build.y + sizey):
						raise _NotBuildableError(BuildableErrorTypes.OTHER_BUILDING_THERE)
		return tearset

	@classmethod
	def _check_units(cls, session, position):
		for tup in position.tuple_iter():
			if tup in session.world.ground_unit_map:
				raise _NotBuildableError(BuildableErrorTypes.UNIT_THERE)


class BuildableSingle(Buildable):
	"""Buildings one can build single. """
	@classmethod
	def check_build_line(cls, session, point1, point2, rotation=45, ship=None):
		# only build 1 building at endpoint
		# correct placement for large buildings (mouse should be at center of building)
		point2 = point2.copy() # only change copy
		point2.x -= (cls.size[0] - 1) // 2
		point2.y -= (cls.size[1] - 1) // 2
		return [cls.check_build_fuzzy(session, point2, rotation=rotation, ship=ship)]


class BuildableSingleEverywhere(BuildableSingle):
	"""Buildings, that can be built everywhere. Usually not used for buildings placeable by humans."""
	terrain_type = None # type: None

	@classmethod
	def check_build(cls, session, point, rotation=45, check_settlement=True, ship=None, issuer=None):
		# for non-quadratic buildings, we have to switch width and height depending on the rotation
		if rotation in [45, 225]:
			position = Rect.init_from_topleft_and_size(point.x, point.y, cls.size[0], cls.size[1])
		else:
			position = Rect.init_from_topleft_and_size(point.x, point.y, cls.size[1], cls.size[0])

		buildable = True
		tearset = []
		return _BuildPosition(position, rotation, tearset, buildable)


class BuildableRect(Buildable):
	"""Buildings one can build as a Rectangle, such as Trees"""
	@classmethod
	def check_build_line(cls, session, point1, point2, rotation=45, ship=None):
		if point1 == point2:
			# this is actually a masked single build
			return [cls.check_build_fuzzy(session, point1, rotation=rotation, ship=ship)]
		possible_builds = []
		area = Rect.init_from_corners(point1, point2)
		# correct placement for large buildings (mouse should be at center of building)
		area.left -= (cls.size[0] - 1) // 2
		area.right -= (cls.size[0] - 1) // 2
		area.top -= (cls.size[1] - 1) // 2
		area.bottom -= (cls.size[1] - 1) // 2

		xstart, xend = area.left, area.right + 1
		xstep = cls.size[0]
		if point1.x > point2.x:
			xstart, xend = area.right, area.left - 1
			xstep *= -1

		ystart, yend = area.top, area.bottom + 1
		ystep = cls.size[1]
		if point1.y > point2.y:
			ystart, yend = area.bottom, area.top - 1
			ystep *= -1

		for x in range(xstart, xend, xstep):
			for y in range(ystart, yend, ystep):
				possible_builds.append(
				  cls.check_build(session, Point(x, y), rotation=rotation, ship=ship)
				)
		return possible_builds


class BuildableLine(Buildable):
	"""Buildings one can build in a line, such as paths"""
	@classmethod
	def check_build_line(cls, session, point1, point2, rotation=45, ship=None):

		# Pathfinding currently only supports buildingsize 1x1, so don't use it in this case
		if cls.size != (1, 1):
			return [cls.check_build_fuzzy(session, point2, rotation=rotation, ship=ship)]

		# use pathfinding to get a path, then try to build along it
		island = session.world.get_island(point1)
		if island is None:
			return []

		if cls.id == BUILDINGS.TRAIL:
			nodes = island.path_nodes.nodes
		elif cls.id == BUILDINGS.BARRIER:
			# Allow nodes that can be walked upon and existing barriers when finding a
			# build path
			nodes = ChainMap(island.path_nodes.nodes, island.barrier_nodes.nodes)
		else:
			raise Exception('BuildableLine does not support building id {0}'.format(cls.id))

		path = a_star_find_path(point1.to_tuple(), point2.to_tuple(), nodes, rotation in (45, 225))
		if path is None: # can't find a path between these points
			return [] # TODO: maybe implement alternative strategy

		possible_builds = []

		#TODO duplicates recalculation code in world.building.path
		for x, y in path:
			action = ''
			for action_char, (xoff, yoff) in \
					sorted(BUILDINGS.ACTION.action_offset_dict.items()): # order is important here
				if action_char in 'abcd' and (xoff + x, yoff + y) in path:
					action += action_char
			if action == '':
				action = 'single' # single trail piece with no neighbors

			build = cls.check_build(session, Point(x, y))
			build.action = action
			possible_builds.append(build)

		return possible_builds


class BuildableSingleOnCoast(BuildableSingle):
	"""Buildings one can only build on coast, such as the fisher."""
	irregular_conditions = True
	terrain_type = TerrainRequirement.LAND_AND_COAST

	@classmethod
	def _check_island(cls, session, position, island=None):
		# ground has to be either coastline or constructible, > 1 tile must be coastline
		# can't use super, since it checks all tiles for constructible

		if island is None:
			island = session.world.get_island(position.center)
			if island is None:
				raise _NotBuildableError(BuildableErrorTypes.NO_ISLAND)

		flat_land_found = False
		coastline_found = False
		first_coords = None
		for coords in position.tuple_iter():
			if first_coords is None:
				first_coords = coords
			# can't use get_tile_tuples since it discards None's
			tile = island.get_tile_tuple(coords)
			if tile is None:
				raise _NotBuildableError(BuildableErrorTypes.NO_ISLAND)
			if 'coastline' in tile.classes:
				coastline_found = True
			elif 'constructible' in tile.classes:
				flat_land_found = True
			elif 'constructible' not in tile.classes: # neither coastline, nor constructible
				raise _NotBuildableError(BuildableErrorTypes.UNFIT_TILE)
		if not coastline_found:
			raise _NotBuildableError(BuildableErrorTypes.NO_COAST)
		elif isinstance(position, Rect) and not flat_land_found:
			# Flat land is required but this function can be called with just a point that
			# is used to show the buildability highlight. In that case the flat land
			# requirement should be ignored.
			raise _NotBuildableError(BuildableErrorTypes.NO_FLAT_LAND)
		return island

	@classmethod
	def _check_rotation(cls, session, position, rotation):
		"""Rotate so that the building faces the seaside"""
		# array of coords (points are True if is coastline)
		coastline = {}
		x, y = position.origin.to_tuple()
		for point in position:
			if session.world.map_dimensions.contains_without_border(point):
				is_coastline = ('coastline' in session.world.get_tile(point).classes)
			else:
				is_coastline = False
			coastline[point.x - x, point.y - y] = is_coastline

		""" coastline looks something like this:
		111
		000
		000
		we have to rotate to the direction with most 1s

		Rotations:
		   45
		135   315
		   225
		"""
		coast_line_points_per_side = {
			45: sum(coastline[(x, 0)] for x in range(0, cls.size[0])),
			135: sum(coastline[(0, y)] for y in range(0, cls.size[1])),
			225: sum(coastline[(x, cls.size[1] - 1)] for x in range(0, cls.size[0])),
			315: sum(coastline[(cls.size[0] - 1, y)] for y in range(0, cls.size[1])),
		}

		# return rotation with biggest value
		maximum = -1
		rotation = -1
		for rot, val in coast_line_points_per_side.items():
			if val > maximum:
				maximum = val
				rotation = rot
		return rotation


class BuildableSingleOnOcean(BuildableSingleOnCoast):
	"""Requires ocean nearby as well"""
	terrain_type = TerrainRequirement.LAND_AND_COAST_NEAR_SEA

	@classmethod
	def _check_island(cls, session, position, island=None):
		# this might raise, just let it through
		super(BuildableSingleOnOcean, cls)._check_island(session, position, island)
		if island is None:
			island = session.world.get_island(position.center)
			if island is None:
				raise _NotBuildableError(BuildableErrorTypes.NO_ISLAND)
		posis = position.get_coordinates()
		for tile in posis:
			for rad in Circle(Point(*tile), 3):
				if rad in session.world.water_body and session.world.water_body[rad] == session.world.sea_number:
					# Found legit see tile
					return island
		raise _NotBuildableError(BuildableErrorTypes.NO_OCEAN_NEARBY)


class BuildableSingleFromShip(BuildableSingleOnOcean):
	"""Buildings that can be build from a ship. Currently only Warehouse."""
	@classmethod
	def _check_settlement(cls, session, position, ship, issuer=None):
		# building from ship doesn't require settlements
		# but a ship nearby:
		if ship.position.distance(position) > BUILDINGS.BUILD.MAX_BUILDING_SHIP_DISTANCE:
			raise _NotBuildableError(BuildableErrorTypes.ONLY_NEAR_SHIP)

		for i in position:
			# and the position mustn't be owned by anyone else
			settlement = session.world.get_settlement(i)
			if settlement is not None:
				raise _NotBuildableError(BuildableErrorTypes.OTHER_PLAYERS_SETTLEMENT)

		# and player mustn't have a settlement here already
		island = session.world.get_island(position.center)
		for s in island.settlements:
			if s.owner == ship.owner:
				raise _NotBuildableError(BuildableErrorTypes.ISLAND_ALREADY_SETTLED)


class BuildableSingleOnDeposit(BuildableSingle):
	"""For mines; those buildings are only buildable upon other buildings (clay pit on clay deposit, e.g.)
	For now, mines can only be built on a single type of deposit.
	This is specified in object files, and saved in cls.buildable_on_deposit in
	the buildingclass.
	"""
	irregular_conditions = True
	terrain_type = None # type: None

	@classmethod
	def _check_buildings(cls, session, position, island=None):
		"""Check if there are buildings blocking the build"""
		if island is None:
			island = session.world.get_island(position.center)
		deposit = None
		for tile in island.get_tiles_tuple(position.tuple_iter()):
			if tile.object is None or \
			   tile.object.id != cls.buildable_on_deposit_type or \
			   (deposit is not None and tile.object != deposit): # only build on 1 deposit
				raise _NotBuildableError(BuildableErrorTypes.NEED_RES_SOURCE)
			deposit = tile.object
		return {deposit.worldid}

	@classmethod
	def _check_rotation(cls, session, position, rotation):
		"""The rotation should be the same as the one of the underlying mountain"""
		tearset = cls._check_buildings(session, position) # will raise on problems
		# rotation fix code is only reached when building is buildable
		mountain = WorldObject.get_object_by_id(next(iter(tearset)))
		return mountain.rotation
