"""Nyhavn, Copenhagen: the coloured harbour houses on the quay, with wooden boats.

Compile Copenhagen, by Amin Bhojani. Built with bricky (Apache 2.0, Maxence Faldor);
helpers for walls, roofs and tiling follow the patterns of bricky's street corner
example. LEGO is a trademark of the LEGO Group, which does not sponsor this model.

- A Baseplate 48 x 48: the canal in front (south), a raised cobbled quay, a row of
  eight narrow houses, and a paved yard behind them.
- Houses are voxels filled with bricks and plates: a dark plinth, a ground floor
  of cafes, upper floors with a white band at each floor line, and roofs of
  Slope 45 courses. Two houses turn a gable to the canal.
- Windows are narrow and tall, set back one stud behind a white sill; cafes have
  red and white awnings.
- Three wooden boats lie in the canal on studs left bare among the water tiles,
  each with a mast and a Danish flag.
"""

from __future__ import annotations

from dataclasses import dataclass, field

from bricky import ModelBuilder

BASE = 0.5  # the top of the baseplate, in plates
SIZE = 48
CANAL = range(0, 20)  # rows of water
QUAY = range(20, 28)  # rows of the quay
FRONT = 28  # the row of the houses' canal-side walls
DEPTH = 10  # houses run from FRONT to FRONT + DEPTH - 1
STREET = 4  # local z of the quay's walking surface (3 plates of quay, 1 of tiles)
GROUND = 12  # plates of ground floor above the street
STORY = 10  # plates per upper floor

TRIM = "White"
GLASS = "Trans-Light Blue"
PLINTH = "Dark Bluish Gray"
COBBLE = "Light Bluish Gray"
WATER = ("Dark Blue", "Blue")
WOOD = "Reddish Brown"

type Voxel = tuple[int, int, int]
type Square = tuple[int, int]

TILE_SIZES = {
    (2, 4): "87079",
    (1, 8): "4162",
    (1, 6): "6636",
    (2, 2): "3068",
    (1, 4): "2431",
    (1, 3): "63864",
    (1, 2): "3069",
    (1, 1): "3070",
}
SLOPES = {4: "3037", 3: "3038", 2: "3039", 1: "3040"}
FALLS = {"-y": "R0", "+y": "R180", "-x": "R270", "+x": "R90"}


@dataclass(slots=True)
class Site:
    builder: ModelBuilder
    voxels: dict[Voxel, str] = field(default_factory=dict)
    bare: set[Square] = field(default_factory=set)


@dataclass(frozen=True, slots=True)
class House:
    x: range
    floors: int  # upper floors
    wall: str
    roof: str
    gable: bool = False  # ridge runs north-south, so a gable faces the canal
    awning: str | None = None  # stripe colour of a cafe awning, or None

    @property
    def y(self) -> range:
        return range(FRONT, FRONT + DEPTH)

    @property
    def top(self) -> int:
        return STREET + GROUND + STORY * self.floors


# Nyhavn's north side, west to east: narrow houses in ochre, red, blue and white.
HOUSES = (
    House(range(0, 6), 3, "Bright Light Orange", "Dark Red", awning="Red"),
    House(range(6, 12), 2, "Dark Red", "Dark Red", gable=True),
    House(range(12, 17), 3, "Medium Blue", PLINTH, awning="Dark Blue"),
    House(range(17, 23), 4, "Bright Light Yellow", "Dark Red", awning="Red"),
    House(range(23, 29), 3, "Orange", "Dark Red"),
    House(range(29, 35), 2, TRIM, "Dark Red", gable=True, awning="Dark Green"),
    House(range(35, 41), 3, "Sand Blue", PLINTH),
    House(range(41, 48), 3, "Yellow", "Dark Red", awning="Red"),
)


def put(site: Site, part: str, at: tuple[float, float, float], color: str,
        rotation: str = "R0", id: str | None = None) -> str:
    x, y, z = at
    return site.builder.place(part, at=(x, y, BASE + z), color=color,
                              rotation=rotation, id=id)


def tile(site: Site, squares: set[Square], z: float, color: str) -> None:
    """Cover squares with tiles, largest first, row by row."""
    covered: set[Square] = set()
    for x, y in sorted(squares, key=lambda s: (s[1], s[0])):
        if (x, y) in covered:
            continue
        for (w, d), part in TILE_SIZES.items():
            done = False
            for sx, sy, rot in ((w, d, "R0"), (d, w, "R90")):
                cells = {(x + i, y + j) for i in range(sx) for j in range(sy)}
                if cells <= squares and not cells & covered:
                    put(site, part, (x, y, z), color, rot)
                    covered |= cells
                    done = True
                    break
            if done:
                break


# Components, all facing -y (the canal).


def components(builder: ModelBuilder) -> None:
    # A narrow window: set back one stud behind a white sill, under a white lintel.
    win = builder.component("window")
    win.place("3023", at=(0, 0, 0), rotation="R0", color=TRIM)  # 1 x 2 plate, both rows
    win.place("3070", at=(0, 0, 1), color=TRIM)  # sill tile in the wall row
    for z in (1, 4):
        win.place("3005", at=(0, 1, z), color=GLASS)
    win.place("3023", at=(0, 0, 7), color=TRIM)
    # A door two studs wide.
    door = builder.component("door")
    for z, c in ((0, WOOD), (3, "Trans-Clear"), (6, WOOD)):
        door.place("3004", at=(0, 1, z), rotation="R90", color=c)
    door.place("3022", at=(0, 0, 9), color=TRIM)
    door.place("3022", at=(0, 0, -1), color=PLINTH)
    # A cafe window two studs wide.
    shop = builder.component("shop-window")
    shop.place("3022", at=(0, 0, 0), color=TRIM)
    for z in (1, 4, 7):
        shop.place("3004", at=(0, 1, z), rotation="R90", color="Trans-Clear")
    shop.place("3022", at=(0, 0, 10), color=TRIM)
    # Danish flag: a red tile with a white cross is out of reach, so red and white.
    flag = builder.component("flag")
    flag.place("3062", at=(0, 0, 0), color=WOOD)
    for z in range(3, 24, 3):
        flag.place("3062", at=(0, 0, z), color=WOOD)
    flag.place("3070", at=(0, 0, 24), color="Red")
    # a cafe table under a parasol
    par = builder.component("parasol")
    for z in (0, 3, 6):
        par.place("3062", at=(0, 0, z), color="Black" if z == 0 else TRIM)
    par.place("14769", at=(0, 0, 9), color="Red")
    stool = builder.component("stool")
    stool.place("4073", at=(0, 0, 0), color="Black")
    stool.place("3024", at=(0, 0, 1), color="Dark Green")
    bollard = builder.component("bollard")
    bollard.place("3062", at=(0, 0, 0), color="Black")
    bollard.place("4073", at=(0, 0, 3), color="Black")


def awning(builder: ModelBuilder, stripe: str) -> None:
    a = builder.component(f"awning-{stripe}")
    a.place("3031", at=(0, -3, 0), color=TRIM)  # Plate 4 x 4, one row in the wall
    for x in range(4):
        a.place("4286", at=(x, -3, 1), color=stripe if x % 2 == 0 else TRIM)


# The canal, the quay and the yard.


def canal(site: Site) -> None:
    boats = [(2, 3, 16), (21, 12, 14), (36, 5, 10)]  # (x, y, length) of each hull
    for bx, by, length in boats:
        site.bare |= {(bx + i, by + j) for i in range(2, length - 2) for j in (1, 2)}
    water = {(x, y) for x in range(SIZE) for y in CANAL} - site.bare
    dark = {(x, y) for x, y in water if (x * 7 + y * 13) % 11 == 0 or (x * 5 + y * 3) % 17 == 0}
    tile(site, water - dark, 0, WATER[0])
    tile(site, dark, 0, WATER[1])
    for n, (bx, by, length) in enumerate(boats):
        boat(site, n, bx, by, length)


def boat(site: Site, n: int, bx: int, by: int, length: int) -> None:
    """A wooden boat four studs wide: a hull on a keel, a deck, a cabin and a mast.

    The keel stands on bare studs among the water tiles; the hull tapers at the ends.
    """
    hull = ("Dark Green", "Dark Red", "Dark Blue")[n]
    v: dict[Voxel, str] = {}
    for i in range(length):
        end = min(i, length - 1 - i)
        rows = [1, 2] if end == 0 else [0, 1, 2, 3]
        for j in rows:
            outer = j in (0, 3) or end == 0
            for z in range(1, 7 if outer else 6):
                if z == 6 or (z == 5 and not outer):
                    v[i, j, z] = TRIM if outer else WOOD
                else:
                    v[i, j, z] = TRIM if z == 3 else hull  # a white stripe
        if 2 <= i < length - 2:
            for j in (1, 2):
                v[i, j, 0] = hull
    # the cabin near the stern, white with a dark roof
    cx = length - 5
    for i in range(cx, cx + 3):
        for j in (1, 2):
            for z in range(6, 12):
                v[i, j, z] = GLASS if (z in (8, 9) and i == cx + 1) else TRIM
            v[i, j, 12] = WOOD
    comp = site.builder.component(f"boat-{n}")
    comp.fill(v, id=f"hull-{n}")
    site.builder.instance(f"boat-{n}", at=(bx, by, BASE), id=f"boat-{n}")
    site.builder.instance("flag", at=(bx + max(3, length // 3), by + 1, BASE + 6), id=f"mast-{n}")


def quay(site: Site) -> None:
    """A cobbled quay three plates up, with a dark edge, bollards and cafe tables."""
    b = site.builder
    bollards = [(x, QUAY.start + 1) for x in range(3, SIZE, 8)]
    tables = [(x, QUAY.start + 4) for x in (2, 14, 20, 26, 32, 44)]
    stools = [(x + dx, QUAY.start + 4) for x, _ in tables for dx in (-1, 2)]
    for x in range(SIZE):
        for y in QUAY:
            for z in range(3):
                site.voxels[x, y, z] = PLINTH if y == QUAY.start else COBBLE
    free = set(bollards) | set(tables) | set(stools)
    edge = {(x, QUAY.start) for x in range(SIZE)}
    stones = {(x, y) for x in range(SIZE) for y in QUAY} - edge - free
    tan = {(x, y) for x, y in stones if (x + 2 * y) % 7 == 0}
    tile(site, edge, 3, PLINTH)
    tile(site, stones - tan, 3, COBBLE)
    tile(site, tan, 3, "Dark Tan")
    for x, y in bollards:
        b.instance("bollard", at=(x, y, BASE + 3))
    for x, y in tables:
        b.instance("parasol", at=(x, y, BASE + 3))
    for x, y in stools:
        b.instance("stool", at=(x, y, BASE + 3))


def yard(site: Site) -> None:
    """The paved yard behind the houses, with three trees."""
    trees = [(6, 42, 3), (24, 43, 3), (40, 41, 2)]
    trunks = {(x, y) for x, y, _ in trees}
    back = {(x, y) for x in range(SIZE) for y in range(FRONT + DEPTH, SIZE)} - trunks
    tile(site, back, 0, PLINTH)
    greens = ("Green", "Dark Green", "Green")
    for tx, ty, r in trees:
        for z in range(9):
            site.voxels[tx, ty, z] = WOOD
        cz = 9 + 3 * r  # widest here; below, the crown widens a stud per plate
        for x in range(tx - r - 1, tx + r + 2):
            for y in range(ty - r - 1, ty + r + 2):
                for z in range(9, cz + 3 * r):
                    flat = ((x - tx) ** 2 + (y - ty) ** 2) ** 0.5
                    if z <= cz:
                        inside = flat <= min(r + 0.4, 0.8 + (z - 9))
                    else:
                        inside = (flat**2 + ((z - cz) / 3) ** 2) ** 0.5 <= r + 0.4
                    if inside and 0 <= x < SIZE and y < SIZE:
                        site.voxels[x, y, z] = greens[(z // 2 + tx) % 2]


# Houses.


def wall_color(h: House, x: int, y: int, z: int) -> str:
    if z < STREET:
        return PLINTH
    front = y == FRONT
    if front and z >= STREET + GROUND and (z - STREET - GROUND) % STORY == 0:
        return TRIM
    if front and z == STREET + GROUND - 1:
        return TRIM
    return h.wall


def shell(site: Site, h: House) -> None:
    for z in range(h.top):
        for x in h.x:
            for y in h.y:
                ring = x in (h.x.start, h.x.stop - 1) or y in (h.y.start, h.y.stop - 1)
                if ring:
                    site.voxels[x, y, z] = wall_color(h, x, y, z)
                elif z == h.top - 1 or z == STREET - 1:
                    site.voxels[x, y, z] = h.wall if z == h.top - 1 else PLINTH


def carve(site: Site, squares: list[Square], zs: range) -> None:
    for sq in squares:
        for z in zs:
            site.voxels.pop((*sq, z), None)


def window_columns(h: House) -> list[int]:
    w = len(h.x)
    inner = {5: [1, 3], 6: [1, 4], 7: [1, 3, 5]}[w]
    return [h.x.start + i for i in inner]


def facade(site: Site, h: House) -> None:
    b = site.builder
    for f in range(h.floors):
        z = STREET + GROUND + STORY * f + 1
        for x in window_columns(h):
            carve(site, [(x, FRONT), (x, FRONT + 1)], range(z, z + 8))
            b.instance("window", at=(x, FRONT, BASE + z))
    # ground floor: a door and a cafe window
    w = len(h.x)
    door_x = h.x.start + 1
    shop_x = h.x.start + w - 3
    carve(site, [(door_x + i, FRONT + j) for i in (0, 1) for j in (0, 1)],
          range(STREET - 1, STREET + 10))
    b.instance("door", at=(door_x, FRONT, BASE + STREET - 1))
    if shop_x >= door_x + 2:
        carve(site, [(shop_x + i, FRONT + j) for i in (0, 1) for j in (0, 1)],
              range(STREET + 1, STREET + 12))
        b.instance("shop-window", at=(shop_x, FRONT, BASE + STREET + 1))
    if h.awning:
        ax = h.x.start + (w - 4) // 2
        az = STREET + GROUND  # on the first floor line, above the cafe
        carve(site, [(ax + i, FRONT) for i in range(4)], range(az, az + 1))
        b.instance(f"awning-{h.awning}", at=(ax, FRONT - 3, BASE + az))


def slope_row(site: Site, span: range, row: int, z: int, course: int,
              fall: str, color: str) -> None:
    along_x = fall in ("-y", "+y")
    widths, room = [], len(span)
    if course % 2 == 1 and room > 3:
        widths.append(2)
        room -= 2
    while room:
        w = 4 if room >= 4 else room
        widths.append(w)
        room -= w
    start = span.start
    for w in widths:
        corner = (start, row, z) if along_x else (row, start, z)
        put(site, SLOPES[w], corner, color, FALLS[fall])
        start += w


def roof(site: Site, h: House) -> None:
    """Courses of Slope 45 over a wall-coloured core, closed by a ridge of cheese slopes."""
    along_x = not h.gable
    span = h.x if along_x else h.y
    low, high = (h.y.start, h.y.stop) if along_x else (h.x.start, h.x.stop)
    falls = ("-y", "+y") if along_x else ("-x", "+x")
    z, course = h.top, 0
    while high - low >= 4:
        for row, fall in ((low, falls[0]), (high - 2, falls[1])):
            slope_row(site, span, row, z, course, fall, h.roof)
        for a in span:
            for b in range(low + 2, high - 2):
                x, y = (a, b) if along_x else (b, a)
                for layer in range(3):
                    site.voxels[x, y, z + layer] = h.wall
        low, high, z, course = low + 1, high - 1, z + 3, course + 1
    rows = range(low, high)
    for a in span:
        for k, row in enumerate(rows):
            x, y = (a, row) if along_x else (row, a)
            if len(rows) == 2:
                put(site, "54200", (x, y, z), h.roof, FALLS[falls[k]])
            else:  # a single middle row: a plate cap
                site.voxels[x, y, z] = h.roof


def build() -> ModelBuilder:
    builder = ModelBuilder("nyhavn")
    site = Site(builder)
    builder.place("4186", at=(0, 0, 0), color="Light Bluish Gray", id="baseplate")
    components(builder)
    for stripe in {h.awning for h in HOUSES if h.awning}:
        awning(builder, stripe)
    canal(site)
    for h in HOUSES:
        shell(site, h)
    for h in HOUSES:
        facade(site, h)
        roof(site, h)
    quay(site)
    yard(site)
    town = builder.component("town")
    town.fill(site.voxels, id="town")
    lx = min(x for x, _, _ in site.voxels)
    ly = min(y for _, y, _ in site.voxels)
    builder.instance("town", at=(lx, ly, BASE), id="town")
    print(f"{len(site.voxels):,} voxels")
    return builder
