"""XYZ 타일 서버에서 BBOX 영역 타일을 다운로드해 하나의 이미지로 합성.

지원:
    - 일반 XYZ 템플릿 (`{x}/{y}/{z}`, 선택적 `{s}` 서브도메인)
    - 줌 자동 하향 조정 (타일 수 상한 400)
    - BBOX에 맞게 크롭 (타일 경계 ≠ 실제 BBOX) + 최종 resize

사용 예:
    from tile_downloader import download_xyz_tiles
    img = download_xyz_tiles(
        "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
        37.5, 129.0, 37.6, 129.1,
        zoom=17, log_fn=print,
    )
"""
from __future__ import annotations

import io
import math
import random
from collections.abc import Callable

import requests
from PIL import Image


_DEFAULT_HEADERS = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
    "Accept": "image/webp,image/apng,image/*,*/*;q=0.8",
}
_SUBDOMAINS = ("0", "1", "2", "3")


def latlon_to_tile(lat: float, lon: float, zoom: int) -> tuple[int, int]:
    """위경도 → WMTS 타일 좌표 (좌상단 기준)."""
    lat_rad = math.radians(lat)
    n = 2 ** zoom
    x = int((lon + 180.0) / 360.0 * n)
    y = int((1.0 - math.log(math.tan(lat_rad) + 1.0 / math.cos(lat_rad)) / math.pi) / 2.0 * n)
    return x, y


def tile_to_latlon(x: int, y: int, zoom: int) -> tuple[float, float]:
    """타일 좌표 → 위경도 (타일 좌상단 모서리)."""
    n = 2 ** zoom
    lon = x / n * 360.0 - 180.0
    lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * y / n)))
    return math.degrees(lat_rad), lon


def _auto_zoom(min_lat: float, min_lon: float, max_lat: float, max_lon: float,
               start_zoom: int, max_tiles: int = 400) -> int:
    """타일 수가 max_tiles 이하가 되는 최대 zoom 반환."""
    for z in range(start_zoom, 10, -1):
        x_min, y_min = latlon_to_tile(max_lat, min_lon, z)
        x_max, y_max = latlon_to_tile(min_lat, max_lon, z)
        if (x_max - x_min + 1) * (y_max - y_min + 1) <= max_tiles:
            return z
    return 11  # 하한


def download_xyz_tiles(url_template: str,
                       min_lat: float, min_lon: float,
                       max_lat: float, max_lon: float,
                       zoom: int = 17,
                       final_size: int = 2048,
                       timeout_s: float = 10.0,
                       log_fn: Callable[[str], None] = print) -> Image.Image:
    """XYZ 타일 서버에서 BBOX 영역 타일을 다운로드·합성해 PIL Image 반환.

    Args:
        url_template: `{x}`, `{y}`, `{z}`, (선택) `{s}` 플레이스홀더 URL
        min_lat/min_lon/max_lat/max_lon: WGS84 bounds
        zoom: 시작 zoom (타일 수 과다 시 자동 하향)
        final_size: 최종 결과 이미지 한 변 픽셀
        timeout_s: per-tile HTTP timeout
        log_fn: 진행 로그 callback

    Returns:
        PIL.Image (RGB, final_size × final_size)

    Raises:
        ValueError: 타일을 하나도 받지 못한 경우
    """
    zoom = _auto_zoom(min_lat, min_lon, max_lat, max_lon, zoom)
    x_min, y_min = latlon_to_tile(max_lat, min_lon, zoom)
    x_max, y_max = latlon_to_tile(min_lat, max_lon, zoom)
    cols = x_max - x_min + 1
    rows = y_max - y_min + 1
    log_fn(f"줌 레벨: {zoom}, 타일 그리드: {cols}x{rows} ({cols * rows}장)")

    tile_size = 256
    merged = Image.new("RGB", (cols * tile_size, rows * tile_size))
    ok = 0
    fail = 0
    first_fail_logged = False

    for ty in range(y_min, y_max + 1):
        for tx in range(x_min, x_max + 1):
            s = random.choice(_SUBDOMAINS)
            url = (url_template
                   .replace("{x}", str(tx))
                   .replace("{y}", str(ty))
                   .replace("{z}", str(zoom))
                   .replace("{s}", s))
            try:
                resp = requests.get(url, headers=_DEFAULT_HEADERS, timeout=timeout_s)
                if resp.status_code == 200 and len(resp.content) > 500:
                    tile_img = Image.open(io.BytesIO(resp.content)).convert("RGB")
                    merged.paste(tile_img,
                                  ((tx - x_min) * tile_size, (ty - y_min) * tile_size))
                    ok += 1
                else:
                    fail += 1
                    if not first_fail_logged:
                        ct = resp.headers.get("Content-Type", "없음")
                        log_fn(f"  타일 실패 [{resp.status_code}] Content-Type: {ct}, 크기: {len(resp.content)}B")
                        first_fail_logged = True
            except requests.exceptions.RequestException as e:
                fail += 1
                if not first_fail_logged:
                    log_fn(f"  네트워크 오류: {e}")
                    first_fail_logged = True

    log_fn(f"타일 다운로드: 성공 {ok}장, 실패 {fail}장")

    if ok == 0:
        raise ValueError("타일을 하나도 받지 못했습니다. 네트워크 연결을 확인하세요.")

    # BBOX 크롭 (타일 경계 ≠ 실제 BBOX)
    grid_lat_max, grid_lon_min = tile_to_latlon(x_min, y_min, zoom)
    grid_lat_min, grid_lon_max = tile_to_latlon(x_max + 1, y_max + 1, zoom)
    img_w = cols * tile_size
    img_h = rows * tile_size

    crop_left = int((min_lon - grid_lon_min) / (grid_lon_max - grid_lon_min) * img_w)
    crop_right = int((max_lon - grid_lon_min) / (grid_lon_max - grid_lon_min) * img_w)
    crop_top = int((grid_lat_max - max_lat) / (grid_lat_max - grid_lat_min) * img_h)
    crop_bottom = int((grid_lat_max - min_lat) / (grid_lat_max - grid_lat_min) * img_h)

    crop_left = max(0, min(crop_left, img_w - 1))
    crop_right = max(crop_left + 1, min(crop_right, img_w))
    crop_top = max(0, min(crop_top, img_h - 1))
    crop_bottom = max(crop_top + 1, min(crop_bottom, img_h))

    cropped = merged.crop((crop_left, crop_top, crop_right, crop_bottom))
    return cropped.resize((final_size, final_size), Image.LANCZOS)
