"""Adaptive tiling for Pro automatic detection mode."""
from __future__ import annotations

import math
from typing import Optional

# Tiles are SENT at the model's native processing size so the server skips a
# 1024->1008 resize (and the ~3% data it discards). the cloud model processes internally at
# 1008 px; aligning the uploaded tile to it means the model receives exactly what
# it operates on. The grid/credit/detail math reads this symbolically (stride =
# int(TILE_SIZE * (1 - OVERLAP_FRACTION)), seam_min_dim = OVERLAP_FRACTION *
# TILE_SIZE * gsd), so the smaller tile stays internally consistent; credits are
# per-tile (one per grid cell), not per-pixel, so the cost framing is unaffected.
# Masks decode at the server-reported width/height and map by mask.shape (see
# cloud_detection.decode_detection_response / polygon_exporter.mask_to_polygons),
# so detections stay geo-exact regardless of this value.
TILE_SIZE = 1008
# 20% is the SAHI-validated default for tiled aerial instance segmentation: wide
# enough that seam-split objects overlap substantially in the strip (so the merge
# step can stitch them) without the redundant-inference cost of 25%+. 15% was on
# the low edge and left thin overlaps the merge could not reconcile.
OVERLAP_FRACTION = 0.20
# Tile budget (hard cap on tiles per run = credits per run). Raised 50 -> 100 ->
# 200 -> 800 (2026-07-03): at 200, a ~10 km industrial zone could not reach the
# object-aware resolution target even at the slider maximum, so the seed fell
# back to a 0.86 m/px grid where the model fragments big buildings into
# sections and misses coverage (measured live; the cut-up results were the
# grid, not the merge). 800 tiles keeps the whole detail walk reachable up to
# ~90 km2 at the building target (0.42 m/px). Memory stays flat (tiles render
# just-in-time, no whole-zone image) and the per-run credit estimate is always
# visible before Detect, so the cost of a finer grid stays a conscious choice.
MAX_TILES = 800
# Target ground footprint per tile (meters). At ~100 m / TILE_SIZE px, a 4-5 m object
# spans ~40-50 px, well above SAM's detection floor. Used to size tiles when the
# source has no native resolution (WMS); native-resolution sources (XYZ) tile at
# their own deepest zoom, which is already finer than this.
DETECTION_TILE_FOOTPRINT_M = 100.0

# the cloud model's measured ground-resolution sweet spot is 0.30-0.45 m/px: inside this
# band instance quality peaks; coarser than ~0.5 m/px IoU collapses on dense
# scenes, and finer than ~0.30 m/px adds no quality, only tiles (= credits).
SWEET_SPOT_MAX_MUPP_M = 0.45
SWEET_SPOT_MIN_MUPP_M = 0.30
# Prompt-less default seed: mid-band rather than the fine edge. The fine edge
# (0.30) gave the sharpest defensible default but spent noticeably more of the
# small free-credit allowance; mid-band keeps the quality justification while
# the object-aware seed (per-prompt targets in `_object_detail_profile`)
# refines or coarsens it as soon as the prompt is known.
DEFAULT_SEED_MUPP_M = 0.35
# Below this the imagery is too coarse for reliable detection: the UI shows a
# "raise detail / zoom in" hint when the chosen level stays above it.
QUALITY_FLOOR_MUPP_M = 0.5
# Kept for backward reference (older callers / docs); the picker now uses the
# sweet-spot band above rather than this single target.
DEFAULT_TARGET_MUPP_M = 0.4
# Cap on how many tiles (= credits) the auto-picked default may spend without
# the user opting in. Set so neighbourhood-scale zones (up to ~2 km) can still
# reach the sweet spot automatically; larger zones land at the finest level that
# fits and surface the "raise detail / zoom in" hint. The user can always push
# the detail slider higher for finer, costlier work, and the credit estimate is
# always visible before Detect. Raised 25 -> 30 alongside the finer default
# target so medium-large zones still reach 0.30 m/px instead of being capped.
DEFAULT_AUTO_TILE_BUDGET = 30

# HARD ceiling on the auto-picked default (the soft budget above may be
# crossed to reach an adequate resolution, but never past this). Free-plan
# users hold a small lifetime allowance, so a default that proposes hundreds
# of credits in one run (e.g. a fine-target prompt like "solar panel" on a
# large zone) would drain it in a single click. Beyond this cap the seed
# falls back to the resolvable / in-budget levels and the user opts into
# finer, costlier grids with the slider (cost is always shown live).
AUTO_SEED_TILE_CAP = 100

# Object-aware auto-detail sizing. The resolution-only default ignores WHAT is
# being detected, so the prompt maps to a per-object TARGET ground resolution
# (the tier table in the plugin's `_object_detail_profile`) bracketing the
# sweet spot above: large objects are already sharp at the coarse edge, small
# ones (trees, cars, solar panels) need finer grids to render at a detectable
# size. The seed is the CHEAPEST detail level reaching that target, so
# distinct prompts land on distinctly different levels.
# Resolution guard for the object-aware picker's fallback: when the target is
# unreachable (huge zone or coarse native resolution), seed the cheapest level
# where the typical object still renders at least this many pixels across
# (resolvable, if not ideal).
AUTO_OBJECT_MIN_PX = 20

# Highest detail level the slider exposes (and the loop bound in
# _max_useful_detail). The longer zone side renders as this many tiles at the
# top. The real ceiling per zone is still MAX_TILES (a finer grid is rejected)
# and the native-resolution clamp, both applied dynamically; this is just the
# static upper bound so the user can divide far finer than the old hard 7.
# Raised 12 -> 20 -> 48 alongside MAX_TILES: the object-aware seed walks
# detail levels until the prompt's target resolution is reached, and at 20 a
# ~10 km zone topped out at 0.61 m/px (still fragmenting big buildings). 48
# keeps the building target (0.42 m/px) reachable up to ~17 km zone sides,
# with MAX_TILES as the real per-zone ceiling.
MAX_DETAIL_LEVEL = 48


class TileManager:
    """Computes tile grids and estimates credits for large images.

    Each tile is a (x_offset, y_offset, width, height) tuple in pixel coords.
    """

    def __init__(
        self,
        tile_size: int = TILE_SIZE,
        overlap_fraction: float = OVERLAP_FRACTION,
        max_tiles: int = MAX_TILES,
    ):
        self.tile_size = tile_size
        self.overlap_fraction = overlap_fraction
        self.max_tiles = max_tiles

    def snap_dimensions(self, width: int, height: int) -> tuple[int, int]:
        """Round dimensions up to the nearest clean tile grid boundary.

        After snapping, compute_grid() will produce only full-size tiles
        (tile_size x tile_size). Use for zone-selected mode only, NOT
        for full-image mode where partial edge tiles are acceptable.
        """
        return self._snap_axis(width), self._snap_axis(height)

    def _snap_axis(self, size: int) -> int:
        """Snap a single axis dimension to the nearest clean grid size."""
        if size <= self.tile_size:
            return size
        stride = int(self.tile_size * (1 - self.overlap_fraction))
        if stride <= 0:
            return size
        n_extra = math.ceil((size - self.tile_size) / stride)
        return self.tile_size + n_extra * stride

    def compute_grid(
        self, image_width: int, image_height: int
    ) -> Optional[list[tuple[int, int, int, int]]]:
        """Compute tile grid for an image.

        Returns:
            List of (x, y, w, h) tuples, or None if exceeds max_tiles.
        """
        if image_width <= self.tile_size and image_height <= self.tile_size:
            return [(0, 0, image_width, image_height)]

        stride = int(self.tile_size * (1 - self.overlap_fraction))
        tiles = []

        # Edge alignment: when a plain stride step would leave a thin partial
        # tile at the far edge (e.g. a 1008x325 bottom strip), snap that LAST
        # tile flush to the edge at full size instead. The cloud model resizes
        # every tile to a fixed square, so a thin strip is stretched (a 325px
        # strip ~3.1x vertically) and its masks come back ragged; a full tile
        # over the same ground reads the strip in true proportions with real
        # context (A/B validated 2026-07-03: equal recall, consistently
        # cleaner outlines). Same tile count, same credits; the extra overlap
        # with the previous row/column is resolved by the merger's dedup like
        # any overlap strip. An axis smaller than one tile keeps its single
        # partial tile (there is nothing to align it against).
        y = 0
        while y < image_height:
            x = 0
            tile_h = min(self.tile_size, image_height - y)
            while x < image_width:
                tile_w = min(self.tile_size, image_width - x)
                tiles.append((x, y, tile_w, tile_h))
                if x + tile_w >= image_width:
                    break
                x += stride
                if x + self.tile_size > image_width >= self.tile_size:
                    x = image_width - self.tile_size
            if y + tile_h >= image_height:
                break
            y += stride
            if y + self.tile_size > image_height >= self.tile_size:
                y = image_height - self.tile_size

        if len(tiles) > self.max_tiles:
            return None

        return tiles

    def estimate_credits(self, image_width: int, image_height: int) -> int:
        """Return number of credits (= tiles) needed, or -1 if exceeds cap."""
        tiles = self.compute_grid(image_width, image_height)
        if tiles is None:
            return -1
        return len(tiles)

    def extract_tile_crop(self, image, x: int, y: int, w: int, h: int):
        """Extract a tile crop from the full image array.

        Args:
            image: (H, W, 3) uint8 numpy array (full image at native resolution)
            x, y: top-left pixel offset of the tile
            w, h: tile dimensions in pixels

        Returns:
            (h, w, 3) uint8 numpy array -- .copy() prevents aliasing bugs when
            the caller zeros out the array for padding.
        """
        return image[y : y + h, x : x + w].copy()  # noqa: E203
