from numba import njit
import cv2
import numpy as np
from scipy.spatial import distance
from typing import Union
from src.shared.typings import Coordinate, CoordinateList, GrayImage, GrayPixel, WaypointList
from src.utils.core import hashit
from src.utils.coordinate import getCoordinateFromPixel, getPixelFromCoordinate
from .config import availableTilesFrictions, breakpointTileMovementSpeed, coordinates, floorsImgs, floorsLevelsImgs, floorsLevelsImgsHashes, floorsPathsSqms, nonWalkablePixelsColors, pixelsColorsValues, tilesFrictionsWithBreakpoints, walkableFloorsSqms
from .extractors import getRadarImage
from .locators import getRadarToolsPosition
from .typings import FloorLevel, TileFriction


# Tibia persists the minimap zoom per character. Both supported zoom levels
# must localize so normal-play settings cannot strand the bot at startup.
RADAR_SCALES = (1.0, 2.0)
RADAR_PALETTE = np.array(
    sorted(set(pixelsColorsValues.values())), dtype=np.int16,
)

# Treppen, Leitern und Loecher tragen auf der Karte eine eigene Farbe.
ZUGANGSPUNKT = pixelsColorsValues['accessPoint']
ANGRENZENDE_RICHTUNGEN = {
    'north': (0, -1),
    'south': (0, 1),
    'west': (-1, 0),
    'east': (1, 0),
}


def getFloorChangeDirection(coordinate: Coordinate) -> Union[str, None]:
    """In welche Richtung man von hier aus die Etage wechselt.

    Aus zwei Wegpunkten allein laesst sich das nicht ableiten, sobald der
    Sprung diagonal aussieht: Wer im Depot von (32349, 32231, 8) nach
    Westen geht, landet auf (32348, 32230, 7) - eine Differenz von -1/-1,
    die genauso nach 'north' aussieht. Genau daran hat der Bot am
    23.07.2026 endlos gegen eine Wand gedrueckt.

    Die Karte weiss es besser: sie kennzeichnet den Uebergang als
    'accessPoint'. Liegt genau ein solches Feld daneben, ist die Richtung
    eindeutig. Sonst gibt es hier keine Antwort - dann bleibt es bei dem,
    was eingestellt ist.
    """
    if coordinate is None:
        return None
    floorLevel = coordinate[2]
    if not 0 <= floorLevel < len(floorsImgs):
        return None
    floorImage = floorsImgs[floorLevel]
    found = []
    for direction, (xStep, yStep) in ANGRENZENDE_RICHTUNGEN.items():
        x, y = getPixelFromCoordinate(
            (coordinate[0] + xStep, coordinate[1] + yStep, floorLevel))
        if not (0 <= x < floorImage.shape[1] and 0 <= y < floorImage.shape[0]):
            continue
        if floorImage[y, x] == ZUGANGSPUNKT:
            found.append(direction)
    return found[0] if len(found) == 1 else None


def _locateRadar(floorImage: GrayImage, radarImage: GrayImage,
                 previousCoordinate: Coordinate = None,
                 routeCoordinates: CoordinateList = None):
    best = None
    quantized = RADAR_PALETTE[
        np.abs(radarImage[:, :, None].astype(np.int16) - RADAR_PALETTE).argmin(axis=2)
    ].astype(np.uint8)
    for scale in RADAR_SCALES:
        rawScaled = radarImage if scale == 1 else cv2.resize(
            radarImage, None, fx=scale, fy=scale, interpolation=cv2.INTER_NEAREST,
        )
        quantizedScaled = quantized if scale == 1 else cv2.resize(
            quantized, None, fx=scale, fy=scale, interpolation=cv2.INTER_NEAREST,
        )
        scaled = rawScaled
        halfWidth = scaled.shape[1] // 2
        halfHeight = scaled.shape[0] // 2
        if previousCoordinate is not None:
            previousX, previousY = getPixelFromCoordinate(previousCoordinate)
            padding = 5
            xStart = max(0, previousX - halfWidth - padding)
            yStart = max(0, previousY - halfHeight - padding)
            xEnd = min(
                floorImage.shape[1],
                previousX + scaled.shape[1] - halfWidth + padding,
            )
            yEnd = min(
                floorImage.shape[0],
                previousY + scaled.shape[0] - halfHeight + padding,
            )
            area = floorImage[yStart:yEnd, xStart:xEnd]
        elif routeCoordinates:
            routePixels = [getPixelFromCoordinate(item) for item in routeCoordinates]
            xStart = max(0, min(item[0] for item in routePixels) - 150)
            yStart = max(0, min(item[1] for item in routePixels) - 150)
            xEnd = min(floorImage.shape[1], max(item[0] for item in routePixels) + 150)
            yEnd = min(floorImage.shape[0], max(item[1] for item in routePixels) + 150)
            area = floorImage[yStart:yEnd, xStart:xEnd]
        else:
            xStart = yStart = 0
            area = floorImage
        if area.shape[0] < scaled.shape[0] or area.shape[1] < scaled.shape[1]:
            continue
        for candidate in (rawScaled, quantizedScaled):
            match = cv2.matchTemplate(area, candidate, cv2.TM_CCOEFF_NORMED)
            _, score, _, position = cv2.minMaxLoc(match)
            if best is None or score > best[0]:
                best = (
                    score,
                    xStart + position[0] + halfWidth,
                    yStart + position[1] + halfHeight,
                    0.5 if previousCoordinate is not None or routeCoordinates else 0.82,
                )
        if previousCoordinate is not None or routeCoordinates:
            mask = (quantizedScaled > 0).astype(np.uint8) * 255
            match = cv2.matchTemplate(
                area, quantizedScaled, cv2.TM_CCORR_NORMED, mask=mask,
            )
            _, score, _, position = cv2.minMaxLoc(match)
            if (np.isfinite(score) and score >= 0.9 and
                    (best is None or score > best[0])):
                best = (
                    score,
                    xStart + position[0] + halfWidth,
                    yStart + position[1] + halfHeight,
                    0.9,
                )
    return best if best is not None and best[0] >= best[3] else None


# TODO: add unit tests
# TODO: add perf
# TODO: get by cached images coordinates hashes
def getCoordinate(screenshot: GrayImage, previousCoordinate: Coordinate = None,
                  waypoints: WaypointList = None) -> Coordinate | None:
    radarToolsPosition = getRadarToolsPosition(screenshot)
    if radarToolsPosition is None:
        return None
    radarImage = getRadarImage(screenshot, radarToolsPosition)
    radarHashedImg = hashit(radarImage)
    hashedCoordinate = coordinates.get(radarHashedImg, None)
    if hashedCoordinate is not None:
        return hashedCoordinate
    floorLevel = getFloorLevel(screenshot)
    if floorLevel is None:
        return None
    radarImage[52, 53] = 128
    radarImage[52, 54] = 128
    radarImage[53, 53] = 128
    radarImage[53, 54] = 128
    radarImage[54, 51] = 128
    radarImage[54, 52] = 128
    radarImage[55, 51] = 128
    radarImage[55, 52] = 128
    radarImage[54, 53] = 128
    radarImage[54, 54] = 128
    radarImage[55, 53] = 128
    radarImage[55, 54] = 128
    radarImage[54, 55] = 128
    radarImage[54, 56] = 128
    radarImage[55, 55] = 128
    radarImage[55, 56] = 128
    radarImage[56, 53] = 128
    radarImage[56, 54] = 128
    radarImage[57, 53] = 128
    radarImage[57, 54] = 128
    routeCoordinates = None if not waypoints else [
        item['coordinate'] for item in waypoints
        if item['coordinate'][2] == floorLevel
    ]
    match = _locateRadar(
        floorsImgs[floorLevel], radarImage,
        previousCoordinate=previousCoordinate,
        routeCoordinates=routeCoordinates,
    )
    if match is None and previousCoordinate is not None and routeCoordinates:
        match = _locateRadar(
            floorsImgs[floorLevel], radarImage,
            routeCoordinates=routeCoordinates,
        )
    matchedGlobally = False
    if match is None and routeCoordinates:
        match = _locateRadar(floorsImgs[floorLevel], radarImage)
        matchedGlobally = match is not None
    if match is None:
        return None
    score, xImgCoordinate, yImgCoordinate, _ = match
    xCoordinate, yCoordinate = getCoordinateFromPixel(
        (xImgCoordinate, yImgCoordinate))
    if (previousCoordinate is None and routeCoordinates and
            not matchedGlobally and score < 0.82 and min(
        abs(item[0] - xCoordinate) + abs(item[1] - yCoordinate)
        for item in routeCoordinates
    ) > 30):
        return None
    return (xCoordinate, yCoordinate, floorLevel)


# TODO: add unit tests
# TODO: add perf
def getFloorLevel(screenshot: GrayImage) -> FloorLevel | None:
    radarToolsPosition = getRadarToolsPosition(screenshot)
    if radarToolsPosition is None:
        return None
    left, top, width, height = radarToolsPosition
    left = left + width + 8
    top = top - 7
    height = 67
    width = 2
    floorLevelImg = screenshot[top:top + height, left:left + width]
    floorImgHash = hashit(floorLevelImg)
    if floorImgHash in floorsLevelsImgsHashes:
        return floorsLevelsImgsHashes[floorImgHash]
    scores = [
        cv2.matchTemplate(
            floorLevelImg, template, cv2.TM_CCOEFF_NORMED,
        )[0, 0]
        for template in floorsLevelsImgs
    ]
    best = int(np.argmax(scores))
    second = sorted(scores, reverse=True)[1]
    return best if scores[best] >= 0.45 and scores[best] - second >= 0.05 else None


# TODO: add unit tests
# TODO: add perf
def getClosestWaypointIndexFromCoordinate(coordinate: Coordinate, waypoints: WaypointList) -> Union[int, None]:
    closestWaypointIndex = None
    closestWaypointDistance = 9999
    for waypointIndex, waypoint in enumerate(waypoints):
        if waypoint['coordinate'][2] != coordinate[2]:
            continue
        waypointDistance = distance.cdist(
            [(waypoint['coordinate'][0], waypoint['coordinate'][1])], [(coordinate[0], coordinate[1])]).flatten()[0]
        if waypointDistance < closestWaypointDistance:
            closestWaypointIndex = waypointIndex
            closestWaypointDistance = waypointDistance
    return closestWaypointIndex


# TODO: add perf
def getBreakpointTileMovementSpeed(charSpeed: int, tileFriction: TileFriction) -> int:
    tileFrictionNotFound = tileFriction not in tilesFrictionsWithBreakpoints
    if tileFrictionNotFound:
        closestTilesFrictions = np.flatnonzero(
            availableTilesFrictions > tileFriction)
        tileFriction = availableTilesFrictions[closestTilesFrictions[0]] if len(
            closestTilesFrictions) > 0 else 250
    availableBreakpointsIndexes = np.flatnonzero(
        charSpeed >= tilesFrictionsWithBreakpoints[tileFriction])
    if len(availableBreakpointsIndexes) == 0:
        return breakpointTileMovementSpeed[1]
    return breakpointTileMovementSpeed.get(availableBreakpointsIndexes[-1] + 1)


# TODO: add unit tests
# TODO: add perf
def getTileFrictionByCoordinate(coordinate: Coordinate) -> TileFriction:
    xOfPixelCoordinate, yOfPixelCoordinate = getPixelFromCoordinate(
        coordinate)
    floorLevel = coordinate[2]
    tileFriction = floorsPathsSqms[floorLevel,
                                   yOfPixelCoordinate, xOfPixelCoordinate]
    return tileFriction


# TODO: add unit tests
# TODO: add perf
def isCloseToCoordinate(currentCoordinate: Coordinate, possibleCloseCoordinate: Coordinate, distanceTolerance: int = 10) -> bool:
    (xOfCurrentCoordinate, yOfCurrentCoordinate, _) = currentCoordinate
    XYOfCurrentCoordinate = (xOfCurrentCoordinate, yOfCurrentCoordinate)
    (xOfPossibleCloseCoordinate, yOfPossibleCloseCoordinate, _) = possibleCloseCoordinate
    XYOfPossibleCloseCoordinate = (
        xOfPossibleCloseCoordinate, yOfPossibleCloseCoordinate)
    euclideanDistance = distance.cdist(
        [XYOfCurrentCoordinate], [XYOfPossibleCloseCoordinate])
    return euclideanDistance <= distanceTolerance


# TODO: add unit tests
# TODO: add perf
# TODO: 2 coordinates was tested. Is very hard too test all coordinates(16 floors * 2560 mapWidth * 2048 mapHeight = 83.886.080 pixels)
# NumbaWarning: Cannot cache compiled function "isCoordinateWalkable" as it uses dynamic globals (such as ctypes pointers and large global arrays)
def isCoordinateWalkable(coordinate: Coordinate) -> bool:
    (xOfPixel, yOfPixel) = getPixelFromCoordinate(coordinate)
    return (walkableFloorsSqms[coordinate[2], yOfPixel, xOfPixel]) == 1


# TODO: add unit tests
# TODO: add perf
def isNonWalkablePixelColor(pixelColor: GrayPixel) -> bool:
    return np.isin(pixelColor, nonWalkablePixelsColors)
