"""Phase O + Phase Q: 컨테이너 기반 공간 할당 + 글자수 예산 + 글루 모델.

Kei 비중 → 컨테이너 px 확정 → 블록 제약 계산 → 편집자 스펙 생성.
LLM 추정이 아닌 결정론적 계산.

주요 함수:
- calculate_container_specs(): Kei 비중 → 역할별 ContainerSpec (Phase O)
- finalize_block_specs(): 컨테이너 크기 → 블록별 내부 스펙 (Phase O)
- calculate_char_budget(): 블록+컨테이너 → 글자수 예산 사전 계산 (Phase Q-3)
- apply_glue_compression(): overflow 시 수학적 간격 축소 (Phase Q-7)
- calculate_trim_chars(): 초과 px → 삭제 글자 수
"""
from __future__ import annotations

import math
import logging
from dataclasses import dataclass, field
from typing import Any

logger = logging.getLogger(__name__)


# ──────────────────────────────────────
# height_cost → px 범위 매핑: catalog.yaml의 블록들에서 동적 구축
# ──────────────────────────────────────
_height_cost_cache: dict[str, tuple[int, int]] | None = None

def _get_height_cost_px_range() -> dict[str, tuple[int, int]]:
    """catalog.yaml의 블록 min_height_px에서 height_cost별 범위를 동적 계산."""
    global _height_cost_cache
    if _height_cost_cache is not None:
        return _height_cost_cache

    from src.block_reference import _load_catalog
    # height_cost별로 min_height_px 수집
    cost_heights: dict[str, list[int]] = {}
    for b in _load_catalog():
        cost = b.get("height_cost", "medium")
        h = b.get("min_height_px", 0)
        if cost not in cost_heights:
            cost_heights[cost] = []
        cost_heights[cost].append(h)

    # 각 cost의 (min, max) 범위 계산
    result = {}
    for cost, heights in cost_heights.items():
        if heights:
            result[cost] = (min(heights), max(heights))
        else:
            result[cost] = (0, 0)

    _height_cost_cache = result
    return result

HEIGHT_COST_ORDER = {"compact": 0, "medium": 1, "large": 2, "xlarge": 3}

# 역할별 zone 매핑 (기본)
ROLE_ZONE_MAP = {
    "본심": "body",
    "배경": "body",
    "첨부": "sidebar",
    "결론": "footer",
}

# 폰트 설정 기본값
DEFAULT_FONT_SIZE_PX = 15.2
DEFAULT_LINE_HEIGHT = 1.7
DEFAULT_AVG_CHAR_WIDTH_PX = 14.4  # fonttools 실측 기반 (Pretendard 한글)
CHAR_WIDTH_RATIO = 0.947          # Pretendard 한글 실측: char_width = font_size × 0.947

# ──────────────────────────────────────
# Phase T-5: 폰트 위계 + 동적 비율 역산
# ──────────────────────────────────────

# 역할별 폰트 위계 범위 (min, max)
# 핵심 원칙: font_size(핵심) > font_size(본심) >= font_size(배경) > font_size(첨부)
FONT_HIERARCHY_RANGE: dict[str, tuple[float, float]] = {
    "핵심":  (14.0, 14.0),   # 고정 14px bold
    "본심":  (12.0, 12.0),   # 고정 12px
    "배경":  (10.0, 12.0),   # 텍스트 양에 따라 조정
    "첨부":  (9.0, 11.0),    # 텍스트 양에 따라 조정
    "결론":  (12.0, 14.0),   # footer 배너용
}

# 역할별 줄 높이 비율
ROLE_LINE_HEIGHT: dict[str, float] = {
    "핵심": 1.4,
    "본심": 1.5,
    "배경": 1.4,
    "첨부": 1.4,
    "결론": 1.3,
}


def _estimate_required_height(
    text_chars: int,
    font_size: float,
    available_width: int,
    line_height_ratio: float = 1.5,
    padding: int | None = None,
) -> int:
    """주어진 폰트 크기로 텍스트를 넣으려면 몇 px 필요한가."""
    from src.fit_verifier import _load_design_tokens
    tokens = _load_design_tokens()
    if padding is None:
        padding = tokens["spacing_block"]
    if text_chars <= 0:
        return padding * 2
    char_width = font_size * CHAR_WIDTH_RATIO
    inner_width = max(tokens["spacing_page"], available_width - padding * 2)
    chars_per_line = max(1, int(inner_width / char_width))
    total_lines = max(1, -(-text_chars // chars_per_line))  # ceil division
    line_height_px = font_size * line_height_ratio
    return int(total_lines * line_height_px) + padding * 2


def calculate_font_hierarchy(
    role_text_lengths: dict[str, int],
    available_width: int | None = None,
) -> dict[str, float]:
    """역할별 폰트 크기를 위계 범위 내에서 텍스트 양 기반으로 확정.

    Phase T 핵심: 위계가 먼저, 컨테이너가 따라간다.

    Args:
        role_text_lengths: {"본심": 500, "배경": 200, "첨부": 300, "결론": 50}
        available_width: 예상 가용 너비 (px)

    Returns:
        {"핵심": 14.0, "본심": 12.0, "배경": 11.0, "첨부": 10.0, "결론": 13.0}
    """
    if available_width is None:
        from src.config import settings
        from src.fit_verifier import _load_design_tokens
        tokens = _load_design_tokens()
        available_width = settings.slide_width - tokens["spacing_page"] * 2

    result = {}

    for role, (font_min, font_max) in FONT_HIERARCHY_RANGE.items():
        text_len = role_text_lengths.get(role, 0)

        if font_min == font_max:
            # 고정 폰트 (핵심, 본심)
            result[role] = font_max
            continue

        # 텍스트 양이 많으면 폰트 축소 (범위 내)
        # max 폰트로 시도 → 안 되면 1px씩 축소
        chosen = font_max
        for fs in [font_max, font_max - 1, font_min]:
            fs = max(font_min, fs)
            required_h = _estimate_required_height(
                text_len, fs, available_width, ROLE_LINE_HEIGHT.get(role, 1.5)
            )
            # 합리적 범위(xlarge 최대 높이 이내)면 이 폰트 사용
            ranges = _get_height_cost_px_range()
            max_reasonable_h = ranges.get("xlarge", (0, 0))[1] if ranges.get("xlarge") else required_h
            if required_h <= max_reasonable_h:
                chosen = fs
                break
            chosen = fs  # 최소 폰트라도 사용

        result[role] = chosen

    # 위계 강제: 핵심 > 본심 >= 배경 > 첨부
    if result.get("배경", 11) > result.get("본심", 12):
        result["배경"] = result["본심"]
    if result.get("첨부", 10) >= result.get("배경", 11):
        result["첨부"] = max(FONT_HIERARCHY_RANGE["첨부"][0], result["배경"] - 1)

    return result


def calculate_dynamic_ratio(
    role_text_lengths: dict[str, int],
    font_hierarchy: dict[str, float],
    slide_width: int = 1280,
    slide_height: int = 720,
    preset: dict[str, Any] | None = None,
) -> tuple[int, int]:
    """sidebar 텍스트 양에서 body:sidebar 비율 역산.

    고정 65:35가 아니라 텍스트 양 기반.

    Returns:
        (body_pct, sidebar_pct) 예: (70, 30) or (65, 35)
    """
    # 프리셋에서 기본 비율 가져오기
    preset_body_pct = 0
    preset_sidebar_pct = 0
    if preset:
        zones = preset.get("zones", {})
        for zone_name, zone_info in zones.items():
            if zone_name == "body":
                preset_body_pct = zone_info.get("width_pct", 0)
            elif zone_name == "sidebar":
                preset_sidebar_pct = zone_info.get("width_pct", 0)

    sidebar_text = role_text_lengths.get("첨부", 0)
    body_text = sum(v for k, v in role_text_lengths.items() if k != "첨부" and k != "결론")

    total_text = body_text + sidebar_text
    if total_text <= 0 or sidebar_text <= 0:
        # sidebar 텍스트 없으면 프리셋의 기본 비율 사용
        if preset_body_pct > 0 and preset_sidebar_pct > 0:
            return (preset_body_pct, preset_sidebar_pct)
        return (100, 0)

    # 텍스트 비율에서 순수 계산
    sidebar_ratio = sidebar_text / total_text
    sidebar_pct = max(1, int(sidebar_ratio * 100))
    body_pct = 100 - sidebar_pct

    return (body_pct, sidebar_pct)


def calculate_design_budget(
    container_height_px: int,
    container_width_px: int,
    block_schema: dict,
    font_size: float,
    padding: int | None = None,
) -> dict:
    """블록 schema 기반 디자인 요소 크기 역산.

    텍스트 영역 확보 후 남은 공간 = 디자인 요소 예산.
    텍스트를 줄이는 것이 아니라 도형/이미지/CSS 요소의 크기를 맞추는 방향.

    Args:
        container_height_px: 컨테이너 높이
        container_width_px: 컨테이너 너비
        block_schema: catalog.yaml의 해당 블록 schema
        font_size: 이 역할의 확정된 폰트 크기 (T-5에서 결정)
        padding: 컨테이너 내부 패딩

    Returns:
        {
            "text_height_px": int,       # 텍스트가 차지하는 높이
            "available_height_px": int,   # 디자인 요소 가용 높이
            "available_width_px": int,    # 디자인 요소 가용 너비
            "max_circle_diameter": int,   # 원형 요소 최대 지름
            "max_img_width": int,         # 이미지 최대 너비
            "max_img_height": int,        # 이미지 최대 높이
            "fits": bool,                 # 디자인 요소가 들어가는지
        }
    """
    from src.fit_verifier import _load_design_tokens
    tokens = _load_design_tokens()
    if padding is None:
        padding = tokens["spacing_block"]

    # 블록 schema에서 텍스트 슬롯별 높이 합산
    text_height = 0
    for slot_name, spec in block_schema.items():
        if slot_name.startswith("max_"):
            continue
        if not isinstance(spec, dict):
            continue
        slot_lines = spec.get("max_lines", 1)
        slot_font = spec.get("font_size", font_size)
        # line-height는 typography constant
        text_height += int(slot_lines * (slot_font * 1.6))

    remaining_height = container_height_px - text_height - padding * 2
    remaining_width = container_width_px - padding * 2
    border_w = tokens.get("border_width", tokens.get("accent_border", 1))

    return {
        "text_height_px": text_height,
        "available_height_px": max(0, remaining_height),
        "available_width_px": max(0, remaining_width),
        "max_circle_diameter": max(0, min(remaining_height, remaining_width) - border_w * 2),
        "max_img_width": max(0, remaining_width),
        "max_img_height": max(0, remaining_height),
        "fits": remaining_height >= 0,
    }


def _estimate_capacity(width_px: int, font_size: float, height_px: int) -> int:
    """주어진 공간에서 수용 가능한 총 글자 수."""
    from src.fit_verifier import _load_design_tokens
    tokens = _load_design_tokens()
    padding = tokens["spacing_block"]
    char_width = font_size * CHAR_WIDTH_RATIO
    inner_width = max(1, width_px - padding * 2)
    chars_per_line = max(1, int(inner_width / char_width))
    inner_height = max(1, height_px - padding * 2)
    line_height_px = font_size * 1.4  # line-height (typography)
    max_lines = max(1, int(inner_height / line_height_px))
    return chars_per_line * max_lines


# ──────────────────────────────────────
# ContainerSpec 데이터 클래스
# ──────────────────────────────────────
@dataclass
class ContainerSpec:
    """역할별 컨테이너 스펙."""
    role: str               # "본심", "배경", "첨부", "결론"
    zone: str               # "body", "sidebar", "footer"
    topic_ids: list[int]    # 이 컨테이너에 속하는 topic ID들
    weight: float           # Kei가 판단한 비중 (0.0~1.0)
    height_px: int          # 컨테이너 높이 (px)
    width_px: int           # 컨테이너 너비 (px)
    max_height_cost: str    # 허용 최대 height_cost ("compact"/"medium"/"large"/"xlarge")
    block_constraints: dict = field(default_factory=dict)  # 블록 내부 제약


# ──────────────────────────────────────
# O-1: 컨테이너 스펙 계산
# ──────────────────────────────────────
def calculate_container_specs(
    page_structure: dict[str, Any],
    topics: list[dict[str, Any]],
    preset: dict[str, Any],
    slide_width: int = 1280,
    slide_height: int = 720,
    gap_px: int = 20,
) -> dict[str, ContainerSpec]:
    """Kei 비중 → 역할별 ContainerSpec 계산.

    결정론적. AI 호출 없음.

    Args:
        page_structure: Kei 판단 {"본심": {"topic_ids": [3], "weight": 0.6}, ...}
        topics: 각 topic의 purpose, role, layer
        preset: 프리셋 zone 정보 (budget_px, width_pct)
        slide_width: 슬라이드 너비 (px)
        slide_height: 슬라이드 높이 (px)
        gap_px: 컨테이너 간 간격 (px)

    Returns:
        {"본심": ContainerSpec(...), "배경": ContainerSpec(...), ...}
    """
    zones = preset.get("zones", {})
    specs: dict[str, ContainerSpec] = {}

    # zone별로 해당 역할들의 비중 합산
    zone_roles: dict[str, list[tuple[str, dict]]] = {}  # zone → [(role, info), ...]
    for role_name, role_info in page_structure.items():
        if not isinstance(role_info, dict):
            continue
        zone = ROLE_ZONE_MAP.get(role_name, "body")
        if zone not in zone_roles:
            zone_roles[zone] = []
        zone_roles[zone].append((role_name, role_info))

    # tokens.css에서 spacing 읽기 (하드코딩 방지)
    from src.fit_verifier import _load_design_tokens
    tokens = _load_design_tokens()
    slide_padding = tokens["spacing_page"]  # --spacing-page

    for zone_name, role_list in zone_roles.items():
        zone_info = zones.get(zone_name, {})
        # zone budget: weight 비율로 전체 가용 공간 배정
        # weight는 초기 배정 비율. before→filled→after에서 조정됨.
        header_height = tokens.get("header_height", 66)
        total_available = slide_height - slide_padding * 2 - header_height - gap_px * 2
        zone_weight_sum = sum(info.get("weight", 0) for _, info in role_list)
        all_weight_sum = sum(
            info.get("weight", 0)
            for roles in zone_roles.values()
            for _, info in roles
        )
        if all_weight_sum > 0 and zone_weight_sum > 0:
            zone_budget = int(total_available * zone_weight_sum / all_weight_sum)
        else:
            # fallback: 프리셋 또는 동적 계산
            zone_budget = zone_info.get("budget_px") or total_available
        zone_width_pct = zone_info.get("width_pct", 0)
        # 패딩 제외 폭: 슬라이드 폭 - 좌우 패딩
        slide_inner_width = slide_width - slide_padding * 2
        zone_width_px = int(slide_inner_width * zone_width_pct / 100) if zone_width_pct > 0 else slide_inner_width

        # 이 zone 안의 역할별 비중 비율 계산
        # Kei가 weight를 반드시 제공해야 함 (없으면 균등 배분)
        total_weight = sum(info.get("weight", 0) for _, info in role_list)
        if total_weight <= 0:
            # weight가 없으면 균등 배분
            total_weight = len(role_list)
            for _, info in role_list:
                info.setdefault("weight", 1)

        # 간격 제외
        total_gap = gap_px * max(0, len(role_list) - 1)
        available = zone_budget - total_gap

        # 최소 높이: catalog에서 가장 작은 블록의 min_height_px
        from src.block_reference import _load_catalog
        min_block_h = min(
            (b.get("min_height_px", 0) for b in _load_catalog() if b.get("min_height_px", 0) > 0),
            default=1,
        )

        for role_name, role_info in role_list:
            weight = role_info.get("weight", 1)
            topic_ids = role_info.get("topic_ids", [])

            # 비중 비율로 높이 할당
            ratio = weight / total_weight
            height_px = max(min_block_h, int(available * ratio))
            # footer는 최소 높이 보장 (font_size * line_height + padding)
            if zone_name == "footer":
                from src.fit_verifier import _load_design_tokens as _ldt_footer
                _ft = _ldt_footer()
                _footer_min = int(14 * _ft.get("line_height_ko", 1.7) + _ft["spacing_page"])
                height_px = max(_footer_min, height_px)

            # 블록 내부 제약 계산 — topic당 높이로 판단
            topic_count = max(1, len(topic_ids))
            per_topic_px = height_px // topic_count

            # height_cost 허용 범위: topic당 높이 기준 (컨테이너 전체가 아님)
            max_cost = _max_allowed_height_cost(per_topic_px)
            font_size, padding, line_h = _determine_typography(height_px // topic_count)
            constraints = _calculate_block_constraints(
                height_px, zone_width_px, topic_count, font_size, line_h, padding
            )
            constraints["font_size_px"] = font_size
            constraints["padding_px"] = padding
            constraints["line_height"] = line_h

            specs[role_name] = ContainerSpec(
                role=role_name,
                zone=zone_name,
                topic_ids=topic_ids,
                weight=weight,
                height_px=height_px,
                width_px=zone_width_px,
                max_height_cost=max_cost,
                block_constraints=constraints,
            )

    logger.info(
        f"[O-1] 컨테이너 스펙: "
        + ", ".join(f"{r}={s.height_px}px({s.max_height_cost})" for r, s in specs.items())
    )
    return specs


# ══════════════════════════════════════
# Phase X-B: 유형 B 컨테이너 생성
# ══════════════════════════════════════
def build_containers_type_b(
    page_structure: dict[str, Any],
    slide_width: int = 1280,
    slide_height: int = 720,
    image_sizes: list[dict] | None = None,
) -> dict[str, ContainerSpec]:
    """유형 B: 상단(top) + 하단 2분할(bottom_left/right) + 결론(footer).

    기존 유형 A(calculate_container_specs)를 건드리지 않는 별도 함수.
    모든 크기는 슬라이드 크기 + weight + zone에서 동적 계산. 하드코딩 없음.

    Args:
        page_structure: Kei 판단 {"핵심목표": {"zone": "top", "topic_ids": [1], "weight": 0.45}, ...}
        slide_width: 슬라이드 너비
        slide_height: 슬라이드 높이
        image_sizes: 이미지 정보 (비율 계산용)
    """
    from src.fit_verifier import _load_design_tokens
    tokens = _load_design_tokens()
    pad = tokens["spacing_page"]
    header_h = tokens.get("header_height", 66)
    gap_block = tokens["spacing_block"]
    gap_small = tokens["spacing_small"]
    inner_w = slide_width - pad * 2

    # 역할을 zone별로 분류
    top_roles = []      # zone=top
    bottom_roles = []   # zone=bottom_left, bottom_right
    footer_role = None  # zone=footer

    for role_name, info in page_structure.items():
        if not isinstance(info, dict):
            continue
        zone = info.get("zone", "")
        if zone == "top":
            top_roles.append((role_name, info))
        elif zone in ("bottom_left", "bottom_right"):
            bottom_roles.append((role_name, info))
        elif zone == "footer":
            footer_role = (role_name, info)

    # 전체 가용 높이: 슬라이드 - 패딩*2 - 헤더 - gap
    total_available = slide_height - pad * 2 - header_h - gap_block

    # footer 높이: weight 비율 (최소 보장)
    footer_weight = footer_role[1].get("weight", 0.1) if footer_role else 0.1
    footer_h_raw = int(total_available * footer_weight)
    _footer_min = int(14 * tokens.get("line_height_ko", 1.7) + pad)
    footer_h = max(_footer_min, footer_h_raw)

    # 중간 영역: footer + gap 제외
    middle_h = total_available - footer_h - gap_block

    # 상단/하단 높이: weight 비율로
    top_weight = sum(info.get("weight", 0) for _, info in top_roles)
    bottom_weight = sum(info.get("weight", 0) for _, info in bottom_roles)
    total_mid_weight = top_weight + bottom_weight
    if total_mid_weight <= 0:
        total_mid_weight = 1

    top_h = int(middle_h * top_weight / total_mid_weight)
    bottom_h = middle_h - top_h - gap_small  # gap_small: 상단-하단 사이

    # 상단: 이미지가 있으면 좌텍스트+우이미지 나란히 → 폭 분할
    img_ratio = 0
    if image_sizes:
        for img in image_sizes:
            r = img.get("ratio", 0)
            if r > 0:
                img_ratio = r
                break

    if img_ratio > 0:
        # 이미지 높이 = top_h, 이미지 폭 = top_h * ratio
        img_w = min(int(top_h * img_ratio), int(inner_w * 0.45))  # 최대 45%
        text_w = inner_w - img_w - gap_block
    else:
        text_w = inner_w
        img_w = 0

    specs = {}

    # 상단 역할
    for role_name, info in top_roles:
        specs[role_name] = ContainerSpec(
            role=role_name,
            zone="top",
            topic_ids=info.get("topic_ids", []),
            weight=info.get("weight", 0),
            height_px=top_h,
            width_px=text_w if img_w > 0 else inner_w,  # 이미지 있으면 텍스트 폭만
            max_height_cost=_max_allowed_height_cost(top_h),
            block_constraints={
                "img_width_px": img_w,
                "img_height_px": top_h if img_w > 0 else 0,
                "has_image": img_w > 0,
            },
        )

    # 하단 역할: 2분할
    bottom_col_w = (inner_w - gap_block) // 2
    for role_name, info in bottom_roles:
        specs[role_name] = ContainerSpec(
            role=role_name,
            zone=info.get("zone", "bottom_left"),
            topic_ids=info.get("topic_ids", []),
            weight=info.get("weight", 0),
            height_px=bottom_h,
            width_px=bottom_col_w,
            max_height_cost=_max_allowed_height_cost(bottom_h),
            block_constraints={},
        )

    # 결론
    if footer_role:
        rn, info = footer_role
        specs[rn] = ContainerSpec(
            role=rn,
            zone="footer",
            topic_ids=info.get("topic_ids", []),
            weight=info.get("weight", 0),
            height_px=footer_h,
            width_px=inner_w,
            max_height_cost="low",
            block_constraints={},
        )

    logger.info(
        f"[X-B-3] 유형 B 컨테이너: "
        + ", ".join(f"{r}={s.height_px}px(w={s.width_px})" for r, s in specs.items())
    )
    return specs


def _max_allowed_height_cost(container_height_px: int) -> str:
    """컨테이너 높이에서 허용되는 최대 height_cost.

    catalog.yaml 블록들의 min_height_px 기반 동적 계산.
    """
    ranges = _get_height_cost_px_range()
    # 높은 cost부터 확인: 컨테이너가 해당 cost의 최소 높이 이상이면 허용
    for cost in ["xlarge", "large", "medium", "compact"]:
        if cost in ranges:
            min_h, _ = ranges[cost]
            if container_height_px >= min_h:
                return cost
    return "compact"


def _determine_typography(per_block_height_px: int) -> tuple[float, int, float]:
    """컨테이너 높이에 따른 폰트/패딩/줄간격 결정.

    font-size와 line-height는 typography constant (허용).
    padding은 tokens.css의 spacing 값에서 가져옴.
    """
    from src.fit_verifier import _load_design_tokens
    tokens = _load_design_tokens()

    # height_cost 범위에서 어떤 급인지 판단
    ranges = _get_height_cost_px_range()
    xlarge_min = ranges.get("xlarge", (0, 0))[0]
    large_min = ranges.get("large", (0, 0))[0]
    medium_min = ranges.get("medium", (0, 0))[0]

    if per_block_height_px >= xlarge_min and xlarge_min > 0:
        return (15.2, tokens["spacing_block"], 1.7)     # font 15.2, padding=--spacing-block, lh 1.7
    elif per_block_height_px >= large_min and large_min > 0:
        return (14.0, tokens["spacing_inner"], 1.6)      # font 14, padding=--spacing-inner, lh 1.6
    elif per_block_height_px >= medium_min and medium_min > 0:
        return (13.0, tokens["spacing_small"], 1.5)      # font 13, padding=--spacing-small, lh 1.5
    else:
        return (12.0, tokens["spacing_small"], 1.4)      # font 12, padding=--spacing-small, lh 1.4


def _calculate_block_constraints(
    height_px: int,
    width_px: int,
    topic_count: int,
    font_size_px: float,
    line_height: float,
    padding_px: int,
) -> dict:
    """컨테이너 크기에서 블록 내부 제약을 수학적으로 계산.

    모든 수치는 입력 파라미터(이전 Stage 결과) + font metric에서 도출.
    """
    per_topic_height = max(1, (height_px - padding_px * 2) // max(1, topic_count))
    line_height_px = font_size_px * line_height
    max_lines = max(1, int(per_topic_height / max(1, line_height_px)))
    # chars_per_line: CHAR_WIDTH_RATIO(font metric)로 계산
    char_width = font_size_px * CHAR_WIDTH_RATIO
    usable_width = max(1, width_px - padding_px * 2)
    chars_per_line = max(1, int(usable_width / max(1, char_width)))
    max_items = max(1, max_lines // 2)
    max_chars_total = max_lines * chars_per_line

    return {
        "max_lines": max_lines,
        "max_items": max_items,
        "chars_per_line": chars_per_line,
        "max_chars_total": max(1, max_chars_total),
        "max_chars_per_item": max(1, max_chars_total // max(1, max_items)),
    }


# ──────────────────────────────────────
# O-1 유틸: topic_id → ContainerSpec 매핑
# ──────────────────────────────────────
def find_container_for_topic(
    topic_id: int | None,
    container_specs: dict[str, ContainerSpec],
) -> ContainerSpec | None:
    """topic_id로 해당 ContainerSpec을 찾는다."""
    if topic_id is None:
        return None
    for spec in container_specs.values():
        if topic_id in spec.topic_ids:
            return spec
    return None


# ──────────────────────────────────────
# O-3: 블록 스펙 확정
# ──────────────────────────────────────
def finalize_block_specs(
    blocks: list[dict[str, Any]],
    container_specs: dict[str, ContainerSpec],
    catalog_map: dict[str, dict] | None = None,
) -> list[dict[str, Any]]:
    """각 블록의 내부 스펙을 컨테이너 크기에 맞게 확정한다.

    결정론적. AI 호출 없음.

    확정 필드:
    - _container_height_px, _container_width_px
    - _max_items, _max_chars_per_item, _max_chars_total
    - _font_size_px, _padding_px, _line_height
    """
    for block in blocks:
        tid = block.get("topic_id")
        spec = find_container_for_topic(tid, container_specs)
        if not spec:
            continue

        # 같은 컨테이너 안의 블록 수 (높이 분배)
        siblings = [b for b in blocks
                    if find_container_for_topic(b.get("topic_id"), container_specs) == spec
                    and b.get("topic_id") is not None]
        sibling_count = max(1, len(siblings))
        # 최소 높이: catalog에서 가장 작은 블록의 min_height_px
        _min_h = min((b.get("min_height_px", 1) for b in _load_catalog() if b.get("min_height_px", 0) > 0), default=1)
        per_block_height = max(_min_h, spec.height_px // sibling_count)

        # 폰트/패딩 결정
        font_size, padding, line_h = _determine_typography(per_block_height)

        # 블록별 제약 계산
        constraints = _calculate_block_constraints(
            per_block_height, spec.width_px, 1, font_size, line_h, padding
        )

        # 블록 타입별 세부 조정
        block_type = block.get("type", "")
        if block_type in ("dark-bullet-list",):
            block["_max_items"] = min(constraints["max_items"], 5)
            block["_max_chars_per_item"] = constraints["max_chars_per_item"]
        elif block_type in ("card-numbered", "card-icon-desc"):
            block["_max_items"] = constraints["max_items"]
            block["_max_chars_per_item"] = constraints["max_chars_per_item"]
        elif block_type in ("compare-2col-split", "compare-3col-badge", "table-simple-striped"):
            block["_max_items"] = constraints["max_items"]  # 행 수
            block["_max_chars_per_item"] = constraints["max_chars_per_item"]
        elif block_type in ("comparison-2col",):
            block["_max_chars_per_item"] = constraints["max_chars_total"] // 2
        elif block_type in ("banner-gradient",):
            block["_max_chars_total"] = constraints["chars_per_line"]
        else:
            pass  # 기본값 사용

        # 공통 필드
        block["_container_height_px"] = per_block_height
        block["_container_width_px"] = spec.width_px
        block["_max_chars_total"] = constraints["max_chars_total"]
        block["_font_size_px"] = font_size
        block["_padding_px"] = padding
        block["_line_height"] = line_h

    logger.info(
        f"[O-3] 블록 스펙 확정: "
        + ", ".join(
            f"t{b.get('topic_id')}={b.get('_container_height_px','?')}px"
            for b in blocks if b.get("topic_id") is not None
        )
    )
    return blocks


# ──────────────────────────────────────
# 기존 유틸 (Phase L 호환)
# ──────────────────────────────────────
def calculate_trim_chars(
    excess_px: int,
    container_width_px: int,
    font_size_px: float = DEFAULT_FONT_SIZE_PX,
    line_height: float = DEFAULT_LINE_HEIGHT,
    avg_char_width_px: float = DEFAULT_AVG_CHAR_WIDTH_PX,
) -> int:
    """초과 px에서 삭제할 글자 수를 계산한다."""
    if excess_px <= 0:
        return 0
    line_height_px = font_size_px * line_height
    lines_to_remove = math.ceil(excess_px / line_height_px)
    chars_per_line = int(container_width_px / avg_char_width_px)
    return max(lines_to_remove * chars_per_line, 10)


# ──────────────────────────────────────
# Phase Q-3: 글자수 예산 계산
# ──────────────────────────────────────

# 블록 유형별 구조적 오버헤드 — catalog.yaml의 padding_overhead_px에서 읽음
def _get_block_overhead(block_type: str) -> int:
    """catalog.yaml에서 블록의 padding_overhead_px를 읽어옴."""
    from src.block_reference import _load_catalog
    for b in _load_catalog():
        if b["id"] == block_type:
            return b.get("padding_overhead_px", 0)
    return 0

# 같은 컨테이너 내 블록 간 gap — tokens.css에서 읽음
def _get_block_gap() -> int:
    from src.fit_verifier import _load_design_tokens
    return _load_design_tokens()["spacing_small"]


def calculate_char_budget(
    block_type: str,
    container_spec: ContainerSpec,
    block_def: dict | None = None,
) -> dict:
    """블록이 컨테이너에서 수용 가능한 최대 글자수를 사전 계산한다.

    Phase Q 핵심: 이 예산이 AI 콘텐츠 생성의 하드 제약.

    Args:
        block_type: 블록 ID (예: "venn-diagram")
        container_spec: 이 topic이 속한 컨테이너
        block_def: catalog.yaml의 블록 정의 (None이면 기본값 사용)

    Returns:
        {
            "total_chars": int,       # 전체 글자수 예산
            "max_items": int,         # 최대 항목 수
            "chars_per_item": int,    # 항목당 최대 글자수
            "font_size_px": float,    # 적용 폰트 크기
            "available_lines": int,   # 가용 줄 수
        }
    """
    topic_count = max(1, len(container_spec.topic_ids))

    # 같은 컨테이너 내 블록 간 gap 차감
    total_gap = _get_block_gap() * max(0, topic_count - 1)
    _min_h2 = min((b.get("min_height_px", 1) for b in _load_catalog() if b.get("min_height_px", 0) > 0), default=1)
    available_container_height = max(_min_h2, container_spec.height_px - total_gap)
    per_topic_px = available_container_height // topic_count

    # 폰트 크기 결정
    font_size, padding, line_h = _determine_typography(per_topic_px)

    # 구조적 오버헤드
    structural = _get_block_overhead(block_type)
    content_height = max(10, per_topic_px - structural)

    # 줄 수 계산
    line_height_px = font_size * line_h
    available_lines = max(1, int(content_height / line_height_px))

    # 한국어 줄당 글자수 (폰트 크기 기반)
    # 패딩 제외: tokens.css의 spacing_page × 2가 아니라 블록 내부 padding
    # 블록 padding은 container_spec.block_constraints에 있을 수 있음
    block_padding = container_spec.block_constraints.get("padding_px", 0)
    usable_width = container_spec.width_px - block_padding * 2 if block_padding else container_spec.width_px
    chars_per_line = max(5, int(usable_width / font_size))

    # 항목 수 제한 (블록 정의 참조)
    max_items_by_space = max(1, available_lines // 2)  # 항목당 최소 2줄
    catalog_max = 10
    catalog_min = 1
    if block_def:
        catalog_max = block_def.get("max_items", 10)
        catalog_min = block_def.get("min_items", 1)
    max_items = min(max_items_by_space, catalog_max)
    max_items = max(max_items, catalog_min)

    total_chars = available_lines * chars_per_line
    chars_per_item = total_chars // max(1, max_items)

    budget = {
        "total_chars": max(20, total_chars),
        "max_items": max_items,
        "chars_per_item": max(10, chars_per_item),
        "font_size_px": font_size,
        "available_lines": available_lines,
    }

    logger.info(
        f"[Q-3] 예산: {block_type} → {budget['total_chars']}자, "
        f"{budget['max_items']}항목, {budget['font_size_px']}px"
    )
    return budget


def calculate_budgets_for_candidates(
    candidates: list[dict],
    container_spec: ContainerSpec,
) -> dict[str, dict]:
    """후보 블록 리스트의 각 블록에 대해 글자수 예산을 계산한다.

    Returns:
        {"block_id": {"total_chars": ..., "max_items": ..., ...}, ...}
    """
    budgets = {}
    for block in candidates:
        block_id = block.get("id", "")
        budgets[block_id] = calculate_char_budget(
            block_id, container_spec, block_def=block
        )
    return budgets


# ──────────────────────────────────────
# Phase Q-7: LaTeX 글루 모델 (overflow 수학적 조정)
# ──────────────────────────────────────

@dataclass
class GlueSpec:
    """LaTeX 글루 모델: 유연한 간격 정의.

    natural: 기본 간격 (px)
    stretch: 늘어날 수 있는 양 (px)
    shrink: 줄어들 수 있는 양 (px)
    """
    natural: float
    stretch: float
    shrink: float


# 간격 유형별 글루 설정
SPACING_GLUE: dict[str, GlueSpec] = {
    "block_gap":    GlueSpec(natural=20, stretch=4, shrink=12),
    "inner_gap":    GlueSpec(natural=16, stretch=4, shrink=8),
    "title_gap":    GlueSpec(natural=8,  stretch=2, shrink=4),
    "container_padding": GlueSpec(natural=16, stretch=0, shrink=8),
}

# 폰트 크기 축소 단계 (이진 탐색용)
FONT_SIZE_STEPS = [15.2, 14.0, 13.0, 12.0, 11.0, 10.0, 9.0, 8.0]


def calculate_glue_absorption(block_count: int) -> float:
    """글루 모델로 흡수 가능한 최대 px를 계산한다.

    Args:
        block_count: 컨테이너 내 블록 수

    Returns:
        흡수 가능한 총 shrink px
    """
    total_shrink = 0.0
    # 블록 간 간격
    total_shrink += SPACING_GLUE["block_gap"].shrink * max(0, block_count - 1)
    # 각 블록 내부 간격
    total_shrink += SPACING_GLUE["inner_gap"].shrink * block_count
    # 제목 간격
    total_shrink += SPACING_GLUE["title_gap"].shrink * block_count
    # 컨테이너 패딩 (상하)
    total_shrink += SPACING_GLUE["container_padding"].shrink * 2

    return total_shrink


def compute_glue_css_overrides(
    excess_px: float,
    block_count: int,
) -> dict[str, str]:
    """overflow excess를 흡수하기 위한 CSS 변수 오버라이드를 계산한다.

    Returns:
        {"--spacing-block": "8px", "--spacing-inner": "8px", ...} 또는
        None (글루만으로 흡수 불가)
    """
    max_absorption = calculate_glue_absorption(block_count)
    if excess_px <= 0:
        return {}

    if excess_px > max_absorption:
        # 글루만으로 부족 — 부분 축소 적용 후 나머지는 폰트 축소 필요
        ratio = 1.0
    else:
        ratio = excess_px / max_absorption

    overrides = {}

    # 비율에 따라 각 간격 축소
    block_gap = SPACING_GLUE["block_gap"]
    new_block_gap = block_gap.natural - block_gap.shrink * ratio
    overrides["--spacing-block"] = f"{new_block_gap:.0f}px"

    inner_gap = SPACING_GLUE["inner_gap"]
    new_inner_gap = inner_gap.natural - inner_gap.shrink * ratio
    overrides["--spacing-inner"] = f"{new_inner_gap:.0f}px"

    padding = SPACING_GLUE["container_padding"]
    new_padding = padding.natural - padding.shrink * ratio
    overrides["--container-padding"] = f"{new_padding:.0f}px"

    logger.info(
        f"[Q-7] 글루 압축: excess={excess_px:.0f}px, "
        f"absorption={max_absorption:.0f}px, ratio={ratio:.2f}"
    )
    return overrides


def find_fitting_font_size(
    current_font_px: float,
    excess_after_glue_px: float,
    available_lines: int,
    chars_per_line: int,
) -> float | None:
    """글루 압축 후에도 남은 overflow를 폰트 축소로 해결할 수 있는지 확인.

    Returns:
        적합한 폰트 크기 (px) 또는 None (불가능)
    """
    for font_size in FONT_SIZE_STEPS:
        if font_size >= current_font_px:
            continue  # 현재보다 같거나 큰 크기는 스킵

        # 이 폰트에서의 줄 높이
        line_height_px = font_size * 1.6  # 한국어 기본
        height_saved = (current_font_px * 1.6 - line_height_px) * available_lines

        if height_saved >= excess_after_glue_px:
            logger.info(
                f"[Q-7] 폰트 축소: {current_font_px}px → {font_size}px "
                f"({height_saved:.0f}px 확보)"
            )
            return font_size

    return None  # 8px에서도 안 맞으면 AI 텍스트 압축 필요
