import matrix
import math
import sys
from pprint import pprint
import shared
from scanf import scanf
from typing import Optional, List, Tuple
from dataclasses import dataclass
from collections import defaultdict


def cityblock(y1, x1, y2, x2):
    return abs(y2 - y1) + abs(x2 - x1)


@dataclass
class Sensor:
    sX: int
    sY: int
    bX: int
    bY: int
    limit: Tuple[int, int] = (0, 0)
    _d: int = None
    _edges: List[Tuple[int, int]] = None
    _border: List[Tuple[int, int]] = None

    def __str__(self):
        return (
            f"Sensor(sX={self.sX}, sY={self.sY}, bX={self.bX},"
            f"bY={self.bY}, d={self._d}, edges={len(self._edges)}, borders={len(self._borders)})"
        )

    @property
    def s(self):
        return (self.sY, self.sX)

    @property
    def b(self):
        return (self.bY, self.bX)

    @property
    def distance(self):
        if self._d is None:
            self._d = cityblock(self.sY, self.sX, self.bY, self.bX)
        return self._d

    def distance_to(self, bY, bX):
        return cityblock(self.sY, self.sX, bY, bX)

    def on_line(self, y):
        midpoint = (y, self.s[1])
        d = self.distance_to(*midpoint)
        if d > self.distance:
            return []

        need = self.distance - d

        start = (y, midpoint[1] - need)
        end = (y, midpoint[1] + need)

        return list(range(start[1], end[1] + 1))

    def in_range(self, bY, bX):
        d = cityblock(self.sY, self.sX, bY, bX)
        if self.d < d:
            return False
        return True

    def in_diamond(self):
        sX, sY = self.sX, self.sY
        up_lim = sY - self.distance
        dn_lim = sY + self.distance

        le_lim = sX - self.distance
        ri_lim = sX + self.distance

        u = (up_lim, sX)
        d = (dn_lim, sX)
        l = (sY, le_lim)
        r = (sY, ri_lim)

        infliction = 1
        height = -1
        for idx, x in enumerate(range(l[1], r[1] + 1)):
            height += infliction
            if (sY, x) == self.s:
                infliction = -1
            for y in range(sY - height, sY + height + 1):
                yield (y, x)

    def edges(self):
        if self._edges:
            return self._edges
        sX, sY = self.sX, self.sY
        up_lim = sY - self.distance
        dn_lim = sY + self.distance

        le_lim = sX - self.distance
        ri_lim = sX + self.distance

        u = (up_lim, sX)
        d = (dn_lim, sX)
        l = (sY, le_lim)
        r = (sY, ri_lim)

        infliction = 1
        height = -1
        edges = set()
        # to left -1  and right + 1
        for idx, x in enumerate(range(l[1], r[1] + 1)):
            height += infliction
            if (sY, x) == self.s:
                infliction = -1
            edges.add((sY - height, x))
            edges.add((sY + height, x))
        self._edges = edges
        return self._edges

    def border(self):
        if self._border:
            return self._border
        sX, sY = self.sX, self.sY
        up_lim = sY - self.distance
        dn_lim = sY + self.distance

        le_lim = sX - self.distance
        ri_lim = sX + self.distance

        u = (up_lim, sX)
        d = (dn_lim, sX)
        l = (sY, le_lim)
        r = (sY, ri_lim)

        infliction = 1
        height = -1
        border = set()
        # to left -1  and right + 1
        for idx, x in enumerate(range(l[1] - 1, r[1] + 2)):
            height += infliction
            if (sY, x) == self.s:
                infliction = -1
            border.add((sY - height, x))
            border.add((sY + height, x))
        self._border = border
        return self._border


def part1(rows, sample=False):
    sensors = []
    sensor_points = []
    beacon_points = []
    ineligible_points = set()
    xSet = set()
    ySet = set()
    for row in rows:
        x, y, bx, by = scanf(
            "Sensor at x=%d, y=%d: closest beacon is at x=%d, y=%d", row
        )
        xSet.add(x)
        xSet.add(bx)
        ySet.add(y)
        ySet.add(by)
        sensors.append(Sensor(sX=x, sY=y, bX=bx, bY=by, limit=(0, 0)))
    minX, maxX = min(xSet), max(xSet)
    minY, maxY = min(ySet), max(ySet)
    limLo = min(minX, minY)
    limHi = max(maxX, maxY)

    for sensor in sensors:
        sensor.limit = (limLo, limHi)
        sensor_points.append(sensor.s)
        beacon_points.append(sensor.b)
        if sample:
            for yx in sensor.in_diamond():
                ineligible_points.add(yx)

    CHECK_ROW = 2000000
    if sample:
        CHECK_ROW = 10

    ineligible = set()
    for s in sensors:
        coll = s.on_line(CHECK_ROW)
        ineligible.update(coll)

    count_ignoring_current_beacons = 0
    for i in ineligible:
        if (CHECK_ROW, i) not in beacon_points:
            count_ignoring_current_beacons += 1

    print(count_ignoring_current_beacons, "with removing beacons, final answer")
    if not sample:
        return

    mx = matrix.matrix_of_size(maxX + 1, maxY + 1)
    for yx in ineligible_points:
        y, x = yx
        if y >= 0 and x >= 0:
            if y <= maxY and x <= maxX:
                mx[y][x] = "#"
    for yx in beacon_points:
        y, x = yx
        if y >= 0 and x >= 0:
            if y <= maxY and x <= maxX:
                mx[y][x] = "B"
    for yx in sensor_points:
        y, x = yx
        if y >= 0 and x >= 0:
            if y <= maxY and x <= maxX:
                mx[y][x] = "S"
    print(matrix.ppmx(mx, pad=False, space=True))


tuning = lambda y, x: y + (4000000 * x)


def part2(rows, sample=False):
    sensors = []
    sensor_points = []
    beacon_points = []
    ineligible_points = set()
    xSet = set()
    ySet = set()
    for row in rows:
        x, y, bx, by = scanf(
            "Sensor at x=%d, y=%d: closest beacon is at x=%d, y=%d", row
        )
        xSet.add(x)
        xSet.add(bx)
        ySet.add(y)
        ySet.add(by)
        sensors.append(Sensor(sX=x, sY=y, bX=bx, bY=by))
    minX, maxX = min(xSet), max(xSet)
    minY, maxY = min(ySet), max(ySet)

    for sensor in sensors:
        _ = sensor.edges()
        _ = sensor.border()
        sensor_points.append(sensor.s)
        beacon_points.append(sensor.b)
        if sample:
            for yx in sensor.in_diamond():
                ineligible_points.add(yx)

    L = 4000000
    if sample:
        L = 20

    borders = defaultdict(int)
    for s in sensors:
        for yx in s.border():
            y, x = yx
            if y > 0 and y <= L and x > 0 and x <= L:
                borders[yx] += 1

    TARGET = None
    for (eY, eX) in borders.keys():
        # print("checking:",(eY,ex))
        away_from = []
        for idx, s in enumerate(sensors):
            d = s.distance_to(eY, eX)
            if d > s.distance:
                away_from.append(s.s)
        if len(away_from) == len(sensors):
            TARGET = (eY, eX)
            print(TARGET, tuning(eY, eX))
            break

    if not sample:
        return

    # """ PRINT OUTPUT """

    mx = matrix.matrix_of_size(maxX + 1, maxY + 1)
    for yx in ineligible_points:
        y, x = yx
        if y >= 0 and x >= 0:
            if y <= maxY and x <= maxX:
                mx[y][x] = "#"

    for yx in beacon_points:
        y, x = yx
        if y >= 0 and x >= 0:
            if y <= maxY and x <= maxX:
                mx[y][x] = "B"

    for yx in sensor_points:
        y, x = yx
        if y >= 0 and x >= 0:
            if y <= maxY and x <= maxX:
                mx[y][x] = "S"

    mx[TARGET[0]][TARGET[1]] = "!"
    matrix.highlight(
        mx,
        blink_green=[
            TARGET,
        ],
    )


def main():
    sample = False
    if sys.argv[-1] == "--sample":
        sample = True
    rows = [row for row in shared.load_rows(15)]

    with shared.elapsed_timer() as elapsed:
        part1(rows, sample)
        print("🕒", elapsed())

    with shared.elapsed_timer() as elapsed:
        part2(rows, sample)
        print("🕒", elapsed())


if __name__ == "__main__":
    main()
