import logging
import math
import time
from contextlib import contextmanager
from dataclasses import dataclass
from functools import lru_cache
from math import sqrt

from zet import database
from zet.database import Point, get_all_lines
from zet.realtime import VehiclePosition

logger = logging.getLogger(__name__)

# Minimal distance of a point to line to be drawn when drawing with braille
MIN_DISTANCE = 0.5

# Ratio is the amount by which longitude and latitude are multiplied to get x and y
MIN_RATIO = 1000
MAX_RATIO = 20_000

# Zoom is one of 12 discrete levels which map to [MIN_RATIO, MAX_RATIO] range
MIN_ZOOM = 1
MAX_ZOOM = 12
DEFAULT_ZOOM = 5

# Zoom to ratio mapping is exponential which feels nicer than linear
_ZOOM_FACTOR = (MAX_RATIO / MIN_RATIO) ** (1 / (MAX_ZOOM - MIN_ZOOM))


def zoom_to_ratio(zoom: int) -> float:
    zoom = min(max(zoom, MIN_ZOOM), MAX_ZOOM)
    return MIN_RATIO * _ZOOM_FACTOR ** (zoom - MIN_ZOOM)


@dataclass
class Bounds:
    center_lat: float
    center_lon: float
    min_lat: float
    max_lat: float
    min_lon: float
    max_lon: float

    @property
    def dlat(self):
        return self.max_lat - self.min_lat

    @property
    def dlon(self):
        return self.max_lon - self.min_lon


@dataclass(eq=True, frozen=True)
class MapSize:
    zoom: int
    ratio: float
    char_width: int
    char_height: int
    dot_width: int
    dot_height: int
    center_lat: float
    center_lon: float
    min_lat: float
    min_lon: float
    max_lat: float
    max_lon: float

    @staticmethod
    def new(char_width: int, char_height: int, center_lat: float, center_lon: float, zoom: int):
        dot_width = char_width * 2
        dot_height = char_height * 4

        ratio = zoom_to_ratio(zoom)
        dlon = dot_width / ratio
        dlat = dot_height / ratio

        min_lat = center_lat - dlat / 2
        min_lon = center_lon - dlon / 2
        max_lat = center_lat + dlat / 2
        max_lon = center_lon + dlon / 2

        return MapSize(
            zoom=zoom,
            ratio=ratio,
            char_width=char_width,
            char_height=char_height,
            dot_width=dot_width,
            dot_height=dot_height,
            center_lat=center_lat,
            center_lon=center_lon,
            min_lat=min_lat,
            min_lon=min_lon,
            max_lat=max_lat,
            max_lon=max_lon,
        )


class Image:
    def __init__(self, width: int, height: int):
        assert width % 2 == 0
        assert height % 4 == 0

        self.width = width
        self.height = height
        self.pixels = bytearray(width * height)

    def draw_line(self, x1: int, y1: int, x2: int, y2: int):
        if x1 == x2 and y1 == y2:
            return

        x_min = min(x1, x2)
        x_max = max(x1, x2)
        x_range = range(x_min, x_max + 1)

        y_min = min(y1, y2)
        y_max = max(y1, y2)
        y_range = range(y_min, y_max + 1)

        for y in y_range:
            for x in x_range:
                if x >= 0 and x < self.width and y >= 0 and y < self.height:
                    if distance(x, y, x1, y1, x2, y2) <= MIN_DISTANCE:
                        self.pixels[y * self.width + x] = 1

    def string(self):
        string = ""
        for y in range(0, self.height, 4):
            for x in range(0, self.width, 2):
                char = self._get_char_at(x, y)
                string += char
            string += "\n"
        return string

    def to_char_list(self):
        for y in range(0, self.height, 4):
            for x in range(0, self.width, 2):
                yield self._get_char_at(x, y)

    def dump(self):
        print(self.string())

    def _get_char_at(self, x, y):
        """
        https://en.wikipedia.org/wiki/Braille_Patterns
        """
        p1 = self.pixels[(y + 0) * self.width + x]
        p2 = self.pixels[(y + 1) * self.width + x]
        p3 = self.pixels[(y + 2) * self.width + x]
        p7 = self.pixels[(y + 3) * self.width + x]
        p4 = self.pixels[(y + 0) * self.width + x + 1]
        p5 = self.pixels[(y + 1) * self.width + x + 1]
        p6 = self.pixels[(y + 2) * self.width + x + 1]
        p8 = self.pixels[(y + 3) * self.width + x + 1]

        n = p8 * 128 + p7 * 64 + p6 * 32 + p5 * 16 + p4 * 8 + p3 * 4 + p2 * 2 + p1

        return chr(10240 + n)


@contextmanager
def perflog(label: str):
    start = time.monotonic()
    yield
    duration = time.monotonic() - start
    logger.info(f"{label} took {duration:.3f}s")


@perflog("draw_map")
@lru_cache
def draw_map(map_size: MapSize) -> list[str]:
    image = Image(map_size.dot_width, map_size.dot_height)
    lines = database.get_lines(
        min_lat=map_size.min_lat,
        min_lon=map_size.min_lon,
        max_lat=map_size.max_lat,
        max_lon=map_size.max_lon,
    )

    def project(lat: float, lon: float):
        x = round((lon - map_size.min_lon) * map_size.ratio)
        y = round((lat - map_size.min_lat) * map_size.ratio)
        return x, map_size.dot_height - y

    for [(lat1, lon1), (lat2, lon2)] in lines:
        x1, y1 = project(lat1, lon1)
        x2, y2 = project(lat2, lon2)
        image.draw_line(x1, y1, x2, y2)

    return list(image.to_char_list())


def draw_full_map(zoom: int):
    ratio = zoom_to_ratio(zoom)
    lines = get_all_lines()
    bounds = get_bounds(lines)

    min_x = math.floor(bounds.min_lon * ratio)
    max_x = math.ceil(bounds.max_lon * ratio)
    min_y = math.floor(bounds.min_lat * ratio)
    max_y = math.ceil(bounds.max_lat * ratio)

    dot_width = max_x - min_x
    dot_height = max_y - min_y

    # Align for braille
    if dot_width % 2 != 0:
        dot_width += 2 - dot_width % 2

    if dot_height % 4 != 0:
        dot_height += 4 - dot_height % 4

    char_width = dot_width // 2
    char_height = dot_height // 2

    map_size = MapSize(
        zoom=zoom,
        ratio=ratio,
        char_width=char_width,
        char_height=char_height,
        dot_width=dot_width,
        dot_height=dot_height,
        center_lat=bounds.center_lat,
        center_lon=bounds.center_lon,
        min_lat=bounds.min_lat,
        min_lon=bounds.min_lon,
        max_lat=bounds.max_lat,
        max_lon=bounds.max_lon,
    )

    return draw_map(map_size)


def place_vehicle_labels(
    bounds: MapSize,
    vehicles: list[VehiclePosition],
) -> dict[tuple[int, int], str]:
    labels: dict[tuple[int, int], str] = {}

    for vehicle in vehicles:
        x = round((vehicle.longitude - bounds.min_lon) * bounds.ratio / 2)
        y = round((vehicle.latitude - bounds.min_lat) * bounds.ratio / 4)

        if x < 0 or y < 0 or x >= bounds.char_width or y >= bounds.char_height:
            continue

        y = bounds.char_height - y
        label = vehicle.route_id

        # If needed, move label left so there's room to place the full label
        min_x = bounds.char_width - len(label) - 1
        if x > min_x:
            x = min_x

        # Avoid overlap with other vehicles by moving along the y axis up or down
        placed = False
        for dy in [0, 1, -1, 2, -2, 3, -3, 4, -4]:
            if y + dy < bounds.char_height:
                # Check overlaps for the lenght of the label and the space after the
                # label so we don't print 1314 for trams 13 and 14 next to each other
                # TODO: this does not always work correctly?
                points = [(x + dx, y + dy) for dx in range(len(label) + 1)]
                if not _any_overlaps(labels, points):
                    for dx, c in enumerate(label):
                        labels[x + dx, y + dy] = c
                    placed = True
                    break

        if not placed:
            # TODO: what if we can't place it?
            logger.warning(f"Cannot place label: '{label}' at {x}:{y}")

    return labels


def _any_overlaps(labels: dict[tuple[int, int], str], points: list[tuple[int, int]]) -> bool:
    for point in points:
        if point in labels:
            return True

    return False


def get_bounds(lines: list[tuple[Point, Point]]) -> Bounds:
    count = 0
    sum_lat = 0
    sum_lon = 0
    min_lat = 180
    min_lon = 180
    max_lat = -180
    max_lon = -180

    for line in lines:
        for lat, lon in line:
            if lat < min_lat:
                min_lat = lat
            if lat > max_lat:
                max_lat = lat
            if lon < min_lon:
                min_lon = lon
            if lon > max_lon:
                max_lon = lon
            sum_lat += lat
            sum_lon += lon
            count += 1

    return Bounds(
        center_lat=sum_lat / count,
        center_lon=sum_lon / count,
        max_lat=max_lat,
        max_lon=max_lon,
        min_lat=min_lat,
        min_lon=min_lon,
    )


def distance(x0: float, y0: float, x1: float, y1: float, x2: float, y2: float) -> float:
    """
    Returns the distance of point (x0, y0) from the line that passes through
    points (x1, y1) and (x2, y2).
    https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line#Line_defined_by_two_points
    """
    dx = x2 - x1
    dy = y2 - y1
    return abs(dy * x0 - dx * y0 + x2 * y1 - y2 * x1) / sqrt(dy**2 + dx**2)


if __name__ == "__main__":
    draw_full_map(12)
