"""One-time migration to a square canvas — pure, so it can be tested alone.

Until v1.48.0 a space had an `aspect`, and coordinates were normalised against
it: x by the width, y by the HEIGHT. Making every canvas square without touching
the numbers would stretch every plan vertically.

Nothing about the drawing changes here. The canvas is padded to a square —
top and bottom for a wide plan, left and right for a tall one — and the
coordinates are re-expressed against that larger box. In render units it is a
uniform scale plus an offset, so angles, room proportions and relative positions
survive exactly. `cell_cm` follows, because the grid is tied to the width: for a
tall plan the width grew, so the same wall would otherwise measure less.
"""
from __future__ import annotations

import logging
from typing import Any

_LOGGER = logging.getLogger(__name__)


def transform_for(aspect: float) -> tuple[float, float, float, float]:
    """(dx, dy, kx, ky) that map old normalised coordinates onto the square.

    x' = dx + x * kx, y' = dy + y * ky. Lengths along an axis scale by that
    axis's factor; both are the same uniform scale in RENDER units, which is
    why angles are preserved.
    """
    a = float(aspect)
    if not a or a <= 0:
        a = 1.0
    k = min(1.0, a)          # how much the old box shrinks inside the square
    kx = k                   # x was normalised by the width
    ky = k / a               # y was normalised by the height (= width / aspect)
    return (1.0 - kx) / 2, (1.0 - ky) / 2, kx, ky


def _pt(p: Any, dx: float, dy: float, kx: float, ky: float) -> Any:
    if isinstance(p, (list, tuple)) and len(p) >= 2:
        return [dx + float(p[0]) * kx, dy + float(p[1]) * ky]
    return p


def migrate_space(space: dict[str, Any]) -> bool:
    """Rewrite one space in place. Returns True when anything was changed."""
    if "aspect" not in space:
        return False
    try:
        aspect = float(space.get("aspect") or 1)
    except (TypeError, ValueError):
        aspect = 1.0
    dx, dy, kx, ky = transform_for(aspect)
    space.pop("aspect", None)

    for room in space.get("rooms") or []:
        if room.get("x") is not None:
            room["x"] = dx + float(room["x"]) * kx
        if room.get("y") is not None:
            room["y"] = dy + float(room["y"]) * ky
        if room.get("w") is not None:
            room["w"] = float(room["w"]) * kx
        if room.get("h") is not None:
            room["h"] = float(room["h"]) * ky
        if room.get("poly"):
            room["poly"] = [_pt(p, dx, dy, kx, ky) for p in room["poly"]]

    for op in space.get("openings") or []:
        op["x"] = dx + float(op.get("x", 0)) * kx
        op["y"] = dy + float(op.get("y", 0)) * ky
        # a length is measured along the wall, and the render scale is uniform
        if op.get("length") is not None:
            op["length"] = float(op["length"]) * kx

    for shape in space.get("decor") or []:
        for a, b, fx, fy in (("x1", "y1", kx, ky), ("x2", "y2", kx, ky), ("x", "y", kx, ky)):
            if shape.get(a) is not None:
                shape[a] = dx + float(shape[a]) * fx
            if shape.get(b) is not None:
                shape[b] = dy + float(shape[b]) * fy
        if shape.get("w") is not None:
            shape["w"] = float(shape["w"]) * kx
        if shape.get("h") is not None:
            shape["h"] = float(shape["h"]) * ky

    # The viewport becomes the whole square rather than the transformed old
    # rectangle. It is what the grid is drawn over and what "fit to screen"
    # fits, so keeping the old box would leave the new margins outside the
    # canvas — no dots, nothing to draw on — which is exactly the room this
    # change was meant to give.
    space["view_box"] = [0.0, 0.0, 1.0, 1.0]

    # The grid pitch is a fraction of the WIDTH. A tall plan just got a wider
    # canvas, so a wall now covers fewer cells; without this every measurement
    # in the plan would silently shrink.
    if kx != 1:
        try:
            cell = float(space.get("cell_cm") or 5)
        except (TypeError, ValueError):
            cell = 5.0
        space["cell_cm"] = round(cell / kx, 4)

    # The image keeps its own proportions and is centred; the space no longer
    # has any of its own.
    if space.get("plan_url") and not space.get("plan_aspect"):
        space["plan_aspect"] = round(aspect, 6)
    return True


def pending_from_config(config: dict[str, Any] | None) -> dict[str, float]:
    """{space_id: old aspect} for every space still carrying one.

    This is the migration INTENT. The two stores are written independently and
    either write can fail, so the intent has to survive on its own: it is saved
    into the layout store BEFORE anything changes (HP-1490-01), and cleared by
    the same write that stores the migrated layout. A crash between the writes
    leaves the intent behind, and the next start finishes the missing half —
    each half is idempotent because its trigger (`aspect` in the config, the
    saved intent for the layout) travels with that half's own write.
    """
    out: dict[str, float] = {}
    for space in (config or {}).get("spaces") or []:
        if "aspect" not in space:
            continue
        try:
            out[str(space.get("id"))] = float(space.get("aspect") or 1) or 1.0
        except (TypeError, ValueError):
            out[str(space.get("id"))] = 1.0
    return out


def migrate_config(config: dict[str, Any], layout: dict[str, Any] | None = None) -> bool:
    """The config half: migrate every space still carrying an `aspect`.

    `layout` is accepted for backward compatibility and migrated with the
    factors found in the config — callers that can crash between store writes
    should use `pending_from_config()` + `migrate_layout()` instead, so the
    layout half does not depend on state the config half just deleted.
    """
    factors = pending_from_config(config)
    if not factors:
        return False
    for space in config.get("spaces") or []:
        migrate_space(space)
    if layout:
        migrate_layout(layout, factors)
    return True


def migrate_layout(layout: dict[str, Any] | None, pending: dict[str, float]) -> bool:
    """The layout half: marker and label positions of the spaces in `pending`."""
    changed = False
    for pos in (layout or {}).values():
        if not isinstance(pos, dict) or str(pos.get("s")) not in pending:
            continue
        dx, dy, kx, ky = transform_for(pending[str(pos.get("s"))])
        if pos.get("x") is not None:
            pos["x"] = dx + float(pos["x"]) * kx
        if pos.get("y") is not None:
            pos["y"] = dy + float(pos["y"]) * ky
        changed = True
    return changed
