"""DA-13a + DA-13b: 2단계 — 디자인 팀장.

Step A: 레이아웃 프리셋 선택 (규칙 기반, LLM 불필요)
Step B: 프리셋 안에서 블록 매핑 + 글자 수 가이드 (Sonnet)
"""
from __future__ import annotations

import json
import logging
import re
from pathlib import Path
from typing import Any

import anthropic

from src.config import settings

logger = logging.getLogger(__name__)

# ──────────────────────────────────────
# 블록별 슬롯 정의
# ──────────────────────────────────────
BLOCK_SLOTS = {
    # headers/ (5개)
    "section-title-with-bg": {"required": ["title_ko"], "optional": ["title_en", "breadcrumb", "bg_image"]},
    "section-header-bar": {"required": ["title"], "optional": ["subtitle"]},
    "topic-left-right": {"required": ["title", "description"], "optional": []},
    "topic-center": {"required": ["title"], "optional": ["subtitle", "description"]},
    "topic-numbered": {"required": ["number", "title"], "optional": ["description", "color"]},
    # cards/ (10개)
    "card-image-3col": {"required": ["cards"], "optional": []},
    "card-text-grid": {"required": ["cards"], "optional": []},
    "card-dark-overlay": {"required": ["cards"], "optional": []},
    "card-tag-image": {"required": ["cards"], "optional": []},
    "card-icon-desc": {"required": ["cards"], "optional": []},
    "card-compare-3col": {"required": ["cards"], "optional": []},
    "card-step-vertical": {"required": ["steps"], "optional": []},
    "card-image-round": {"required": ["cards"], "optional": []},
    "card-stat-number": {"required": ["stats"], "optional": []},
    "card-numbered": {"required": ["items"], "optional": []},
    # tables/ (3개)
    "compare-3col-badge": {"required": ["headers", "rows"], "optional": []},
    "compare-2col-split": {"required": ["left_title", "right_title", "rows"], "optional": []},
    "table-simple-striped": {"required": ["headers", "rows"], "optional": []},
    # visuals/ (10개)
    "venn-diagram": {"required": ["center_label", "items"], "optional": ["center_sub", "description"]},
    "circle-gradient": {"required": ["label"], "optional": ["sub_label"]},
    "compare-pill-pair": {"required": ["left_label", "right_label"], "optional": ["left_sub", "right_sub"]},
    "process-horizontal": {"required": ["steps"], "optional": []},
    "flow-arrow-horizontal": {"required": ["steps"], "optional": []},
    "keyword-circle-row": {"required": ["keywords"], "optional": []},
    "layer-diagram": {"required": ["layers"], "optional": ["title"]},
    "timeline-vertical": {"required": ["events"], "optional": []},
    "timeline-horizontal": {"required": ["events"], "optional": []},
    "pyramid-hierarchy": {"required": ["levels"], "optional": []},
    # emphasis/ (12개)
    "quote-left-border": {"required": ["quote_text"], "optional": ["source"]},
    "quote-big-mark": {"required": ["quote_text"], "optional": ["source"]},
    "quote-question": {"required": ["question"], "optional": ["description"]},
    "conclusion-accent-bar": {"required": ["conclusion_text"], "optional": ["label"]},
    "comparison-2col": {"required": ["left_title", "left_content", "right_title", "right_content"], "optional": ["left_subtitle", "right_subtitle"]},
    "banner-gradient": {"required": ["text"], "optional": ["sub_text"]},
    "dark-bullet-list": {"required": ["bullets"], "optional": ["title"]},
    "highlight-strip": {"required": ["segments"], "optional": []},
    "callout-solution": {"required": ["title", "description"], "optional": ["icon", "source"]},
    "callout-warning": {"required": ["title", "description"], "optional": ["icon"]},
    "tab-label-row": {"required": ["tabs"], "optional": []},
    "divider-text": {"required": ["text"], "optional": []},
    "details-block": {"required": ["summary_text", "detail_content"], "optional": ["label"]},
    # media/ (5개)
    "image-row-2col": {"required": ["images"], "optional": []},
    "image-grid-2x2": {"required": ["images"], "optional": []},
    "image-side-text": {"required": ["image_src"], "optional": ["image_alt", "title", "description", "bullets"]},
    "image-full-caption": {"required": ["src"], "optional": ["alt", "caption"]},
    "image-before-after": {"required": ["before_src", "after_src"], "optional": ["before_label", "after_label", "caption"]},
}

# ──────────────────────────────────────
# 슬라이드 물리적 제약
# ──────────────────────────────────────
# 프레임: 1280×720px, 패딩 40px×4 → 가용 1200×640px
# grid gap: 20px, header ~50px, footer ~60px
# → 본문 zone 가용 높이 ≈ 490px (640 - 50 - 20 - 60 - 20)
FRAME_AVAILABLE_HEIGHT = 490

# ──────────────────────────────────────
# 레이아웃 프리셋 정의
# zone별 budget_px = 해당 zone에 넣을 수 있는 최대 높이
# width_pct = zone의 가로 비율 (블록 선택 시 참고)
# ──────────────────────────────────────
LAYOUT_PRESETS = {
    "sidebar-right": {
        "description": "좌측 본문 흐름 + 우측 참조 사이드바",
        "grid_areas": "'header header' 'body sidebar' 'footer footer'",
        "grid_columns": "65fr 35fr",
        "grid_rows": "auto 1fr auto",
        "zones": {
            "header": {"desc": "슬라이드 제목. 자동 크기.", "budget_px": 50, "width_pct": 100},
            "body": {"desc": "flow 꼭지 배치 (위→아래 순서).", "budget_px": 490, "width_pct": 65},
            "sidebar": {"desc": "reference 꼭지. 좁으므로 card-grid 1열, 시각화 블록 금지.", "budget_px": 490, "width_pct": 35},
            "footer": {"desc": "결론 꼭지. 전체 너비.", "budget_px": 60, "width_pct": 100},
        },
    },
    "two-column": {
        "description": "대등한 2단 비교",
        "grid_areas": "'header header' 'left right' 'footer footer'",
        "grid_columns": "1fr 1fr",
        "grid_rows": "auto 1fr auto",
        "zones": {
            "header": {"desc": "슬라이드 제목. 자동 크기.", "budget_px": 50, "width_pct": 100},
            "left": {"desc": "첫 번째 비교 대상.", "budget_px": 490, "width_pct": 50},
            "right": {"desc": "두 번째 비교 대상.", "budget_px": 490, "width_pct": 50},
            "footer": {"desc": "결론 꼭지.", "budget_px": 60, "width_pct": 100},
        },
    },
    "hero-detail": {
        "description": "고강조 1개 + 보조 상세",
        "grid_areas": "'header header' 'hero hero' 'detail detail' 'footer footer'",
        "grid_columns": "1fr 1fr",
        "grid_rows": "auto 2fr 1fr auto",
        "zones": {
            "header": {"desc": "슬라이드 제목. 자동 크기.", "budget_px": 50, "width_pct": 100},
            "hero": {"desc": "고강조 꼭지 (크게).", "budget_px": 310, "width_pct": 100},
            "detail": {"desc": "나머지 보조 꼭지.", "budget_px": 155, "width_pct": 100},
            "footer": {"desc": "결론 꼭지.", "budget_px": 60, "width_pct": 100},
        },
    },
    "single-column": {
        "description": "단일 컬럼 순차 배치",
        "grid_areas": "'header' 'body' 'footer'",
        "grid_columns": "1fr",
        "grid_rows": "auto 1fr auto",
        "zones": {
            "header": {"desc": "슬라이드 제목. 자동 크기.", "budget_px": 50, "width_pct": 100},
            "body": {"desc": "모든 꼭지 위→아래 순서.", "budget_px": 490, "width_pct": 100},
            "footer": {"desc": "결론 꼭지.", "budget_px": 60, "width_pct": 100},
        },
    },
}


# ──────────────────────────────────────
# Step A: 프리셋 선택 (규칙 기반)
# ──────────────────────────────────────
def select_preset(analysis: dict[str, Any]) -> str:
    """실장의 role 분석을 보고 레이아웃 프리셋을 자동 선택한다.

    LLM 호출 불필요. 규칙 기반.
    """
    topics = analysis.get("topics", [])

    has_reference = any(
        t.get("role") == "reference" for t in topics
    )
    flow_topics = [t for t in topics if t.get("role", "flow") == "flow"]
    high_emphasis = [t for t in flow_topics if t.get("emphasis")]

    # reference 꼭지가 있으면 sidebar
    if has_reference:
        preset = "sidebar-right"
    # flow 꼭지가 정확히 2개이고 대등 비교이면 two-column
    elif (
        len(flow_topics) == 2
        and all(t.get("layer") == "core" for t in flow_topics)
    ):
        preset = "two-column"
    # 고강조 1개 + 나머지가 보조이면 hero
    elif (
        len(high_emphasis) == 1
        and len(flow_topics) >= 3
    ):
        preset = "hero-detail"
    # 기본: single-column
    else:
        preset = "single-column"

    logger.info(f"[Step A] 프리셋 선택: {preset}")
    return preset


# ──────────────────────────────────────
# Step B: 프리셋 내 블록 매핑 (Sonnet)
# ──────────────────────────────────────
def _load_catalog() -> str:
    """catalog.yaml 로드."""
    catalog_path = Path(__file__).parent.parent / "templates" / "catalog.yaml"
    if catalog_path.exists():
        return catalog_path.read_text(encoding="utf-8")

    return """사용 가능한 블록:
- quote-question: 질문형 강조. 문제 제기, 전환점.
- compare-box: 2개 키워드 시각 대비.
- comparison-table: 다항목 비교 테이블.
- card-image: 이미지+텍스트 카드.
- card-dark-overlay: 다크 배경 키워드 카드.
- relationship: 벤 다이어그램. 포함/상위-하위 관계.
- process: 단계 흐름. 절차.
- topic-header: 꼭지 제목+설명.
- conclusion-bar: 하단 결론 바.
- banner-gradient: 섹션 강조 배너."""


STEP_B_PROMPT = """당신은 디자인 팀장이다. 레이아웃 프리셋이 이미 선택되었다.
당신의 핵심 역할: **컨테이너(zone)의 크기 예산 안에서** 블록을 배정하는 것이다.

## 슬라이드 물리적 제약 (절대 조건)
- 프레임: 1280×720px (16:9 고정)
- 패딩: 상하좌우 40px → 가용 영역: 1200×640px
- 블록 간 간격: 20px
- **overflow: hidden** — 넘치는 콘텐츠는 잘려서 보이지 않는다!

## 선택된 레이아웃 프리셋: {preset_name}
{preset_description}

### CSS Grid (변경하지 마라):
grid-template-areas: {grid_areas}
grid-template-columns: {grid_columns}
grid-template-rows: {grid_rows}

### Zone별 컨테이너 예산:
{zone_descriptions}

## ★ 사고 순서 (반드시 이 순서로 판단하라)

### 1단계: 컨테이너 크기 확인
위 zone별 높이 예산(px)과 너비(%)를 확인한다. 이것이 절대 제약이다.
header/footer는 고정이므로 건드리지 않는다.

### 2단계: 꼭지 → zone 배정
- flow 꼭지 → body / left / hero zone
- reference 꼭지 → sidebar zone
- detail_target 꼭지 → details-block으로 배치 (해당 zone에 접기/펼치기)
- conclusion 꼭지 → footer zone

### 3단계: zone별 블록 선택 + 높이 예산 계산
각 zone에 대해:
  a) 배정된 꼭지 수를 확인한다
  b) catalog에서 블록을 선택한다 (각 블록의 height_cost 확인!)
  c) 총 높이를 계산한다: Σ(블록 height_cost) + 간격(20px × (블록수-1))
  d) **총 높이 ≤ zone 예산** 인지 반드시 확인한다
  e) 초과 시: ① 더 작은(compact) 블록으로 교체 ② 꼭지를 다음 페이지로 분리

### 4단계: 최종 검증
모든 zone의 블록 총 높이가 예산 이내인지 재확인한 후 출력한다.

## 블록 선택 규칙
- **텍스트 블록 우선** — 텍스트로 충분히 전달 가능하면 시각화(SVG) 블록 쓰지 마라
- **시각화 블록(relationship, process 등)은 높이 비용이 매우 크다** — 한 zone에 시각화 블록은 최대 1개, 다른 블록과 함께 쌓지 마라
- 너비 35% 이하 zone(sidebar)에는 카드 1열, 시각화 블록 금지
- catalog의 when/not_for와 height_cost를 반드시 읽고 선택
- 같은 블록 타입 반복 금지 — 다양한 블록 활용
- 같은 내용이 두 블록에 중복되면 안 된다

## 사용 가능한 블록 (catalog)
{catalog}

## 출력 형식 (반드시 JSON만. 설명 없이.)
grid는 이미 확정되었으므로 출력하지 마라. blocks 배열만 출력한다.
```json
{{{{
    "blocks": [
        {{{{
            "area": "zone이름",
            "type": "블록타입",
            "topic_id": 1,
            "reason": "이유",
            "size": "small|medium|large",
            "char_guide": {{{{"slot": 글자수}}}}
        }}}}
    ]
}}}}
```"""


async def _opus_block_recommendation(
    analysis: dict[str, Any],
    block_candidates: str,
    preset_name: str,
    preset: dict[str, Any],
) -> dict[str, Any] | None:
    """P2-C: Opus(Kei API)가 블록 후보에서 최종 블록을 추천한다.

    Kei API를 통해 Opus가 사고하여:
    - 각 꼭지에 가장 적합한 블록 선정
    - 배치 방향/크기 가이드 제시
    - 도메인 지식 기반 판단

    반드시 Kei API 경유. Anthropic 직접 호출 절대 금지.
    fallback: None 반환 → Step B(Sonnet)가 직접 선택.
    """
    import httpx

    kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")

    zone_desc = "\n".join(
        f"- {name}: {z['desc']} [높이: ~{z['budget_px']}px, 너비: {z['width_pct']}%]"
        for name, z in preset["zones"].items()
    )

    topics_text = "\n".join(
        f"- 꼭지 {t.get('id', '?')}: {t.get('title', '')} "
        f"[{t.get('layer', '?')}, {t.get('role', 'flow')}, 강조:{t.get('emphasis', False)}]"
        for t in analysis.get("topics", [])
    )

    prompt = (
        f"슬라이드 디자인 블록 추천을 해줘.\n\n"
        f"## 프리셋: {preset_name}\n{preset['description']}\n\n"
        f"## Zone 구조\n{zone_desc}\n\n"
        f"## 꼭지 목록\n{topics_text}\n\n"
        f"## 블록 후보 (FAISS 검색 결과)\n{block_candidates}\n\n"
        f"## 요청\n"
        f"각 꼭지에 가장 적합한 블록을 추천해줘.\n"
        f"도메인 지식을 활용하여 콘텐츠 성격에 맞는 블록을 선택하고,\n"
        f"zone별 높이 예산을 고려하여 배치 방향과 크기 가이드를 제시해.\n\n"
        f"## 출력 형식 (JSON만)\n"
        f'{{"recommendations": ['
        f'{{"topic_id": 1, "block_type": "...", "area": "...", '
        f'"reason": "도메인 관점에서 이 블록이 적합한 이유", '
        f'"size_guide": "compact|medium|large"}}]}}'
    )

    try:
        async with httpx.AsyncClient(timeout=None) as client:
            response = await client.post(
                f"{kei_url}/api/message",
                json={
                    "message": prompt,
                    "session_id": "design-agent-opus",
                    "mode": "chat",
                },
                timeout=None,
            )

            if response.status_code != 200:
                logger.warning(f"[Step A-2] Kei API HTTP {response.status_code}")
                return None

            # SSE 응답 파싱 (kei_client.py와 동일 패턴)
            import re
            tokens = []
            events = re.split(r'\r?\n\r?\n', response.text)
            for event in events:
                if not event.strip():
                    continue
                event_type = ""
                event_data = ""
                for line in event.split('\n'):
                    line = line.strip('\r')
                    if line.startswith('event:'):
                        event_type = line[6:].strip()
                    elif line.startswith('data:'):
                        event_data = line[5:].strip()
                if event_type == 'token' and event_data:
                    try:
                        import json as _json
                        token = _json.loads(event_data)
                        if isinstance(token, str):
                            tokens.append(token)
                    except Exception:
                        tokens.append(event_data)
                elif event_type == 'done':
                    break

            full_text = "".join(tokens)
            if not full_text:
                logger.warning("[Step A-2] Kei API 응답 텍스트 없음")
                return None

            result = _parse_json(full_text)
            if result and "recommendations" in result:
                logger.info(
                    f"[Step A-2] Opus 블록 추천 완료: "
                    f"{len(result['recommendations'])}개"
                )
                return result

            logger.warning(f"[Step A-2] JSON 파싱 실패: {full_text[:200]}")
            return None

    except Exception as e:
        logger.warning(f"[Step A-2] Kei API 호출 실패: {e}")
        return None


async def create_layout_concept(
    content: str,
    analysis: dict[str, Any],
) -> dict[str, Any]:
    """2단계: Step A(프리셋) + Step B(블록 매핑).

    Args:
        content: 원본 텍스트
        analysis: 1단계 실장의 꼭지 분석 결과

    Returns:
        레이아웃 컨셉 JSON
    """
    # Step A: 프리셋 선택 (규칙 기반)
    preset_name = select_preset(analysis)
    preset = LAYOUT_PRESETS[preset_name]

    # Step B: 프리셋 내 블록 매핑 (Sonnet)
    client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)

    # P2-A: FAISS 검색으로 관련 블록만 추출 (fallback: catalog 전문)
    from src.block_search import search_blocks_for_topics
    topics = analysis.get("topics", [])
    catalog_text = search_blocks_for_topics(topics, top_k_per_topic=3, total_max=10)
    logger.info(f"[Step A] 블록 후보 검색 완료 (FAISS)")

    # P2-C: Step A-2 — Opus(Kei API)가 블록 추천
    opus_recommendation = await _opus_block_recommendation(
        analysis, catalog_text, preset_name, preset
    )
    opus_hint = ""
    if opus_recommendation and opus_recommendation.get("recommendations"):
        recs = opus_recommendation["recommendations"]
        hint_lines = ["## Opus(실장) 블록 추천 (참고, 최종 선택은 팀장 판단)"]
        for rec in recs:
            hint_lines.append(
                f"- 꼭지 {rec.get('topic_id', '?')}: "
                f"{rec.get('block_type', '?')} ({rec.get('area', '?')}) "
                f"— {rec.get('reason', '')}"
            )
        opus_hint = "\n".join(hint_lines)
        logger.info(f"[Step A-2] Opus 추천 {len(recs)}개 → Step B에 전달")
    else:
        logger.info("[Step A-2] Opus 추천 없음 (Kei API 미연결 또는 실패). Step B가 직접 선택.")

    # zone 설명 텍스트 (높이 예산 + 너비 포함)
    zone_desc = "\n".join(
        f"- {name}: {z['desc']} [높이 예산: ~{z['budget_px']}px, 너비: {z['width_pct']}%]"
        for name, z in preset["zones"].items()
    )

    # 꼭지 요약
    topics_summary = []
    for t in analysis.get("topics", []):
        role = t.get("role", "flow")
        line = (
            f"꼭지 {t.get('id', '?')}: {t.get('title', '?')} "
            f"[{t.get('layer', '?')}, ROLE:{role}, "
            f"강조:{t.get('emphasis', False)}]"
        )
        if t.get("detail_target"):
            line += " → ★detail_target (details-block으로 배치: 요약+상세 접기/펼치기)"
        topics_summary.append(line)

    system = STEP_B_PROMPT.format(
        preset_name=preset_name,
        preset_description=preset["description"],
        grid_areas=preset["grid_areas"],
        grid_columns=preset["grid_columns"],
        grid_rows=preset["grid_rows"],
        zone_descriptions=zone_desc,
        catalog=catalog_text,
    )

    info_structure = analysis.get("info_structure", "")

    # 이미지 크기 정보 (D-2/D-3: Pillow 측정 결과)
    image_info = ""
    image_sizes = analysis.get("image_sizes", [])
    if image_sizes:
        image_lines = []
        for img in image_sizes:
            line = f"- {img['path']}: {img['width']}×{img['height']}px, {img['orientation']}"
            if img.get("has_text"):
                line += " (텍스트 포함 도표 — 과도한 축소 금지)"
            image_lines.append(line)
        image_info = (
            "\n\n## 이미지 크기 정보\n"
            "가로형(landscape) → 전체 너비 배치 권장. "
            "세로형(portrait) → 텍스트 옆 배치 권장. "
            "텍스트 포함 도표 → 과도한 축소 금지.\n"
            + "\n".join(image_lines)
        )

    # Opus 추천이 있으면 user_prompt에 포함
    opus_section = ""
    if opus_hint:
        opus_section = f"\n\n{opus_hint}\n"

    user_prompt = (
        f"## 실장 분석 결과\n"
        f"제목: {analysis.get('title', '')}\n"
        f"정보 구조: {info_structure}\n\n"
        f"꼭지 목록:\n" + "\n".join(topics_summary) +
        image_info +
        opus_section +
        f"\n\n## 원본 콘텐츠 (분량 참고)\n{content[:2000]}\n\n"
        f"## 요청\n"
        f"위 꼭지를 프리셋의 zone에 배정하고 블록 타입을 선택해줘.\n"
        f"Opus 추천이 있으면 참고하되, 최종 선택은 팀장 판단.\n"
        f"JSON만."
    )

    try:
        response = await client.messages.create(
            model="claude-sonnet-4-20250514",
            max_tokens=2048,
            system=system,
            messages=[{"role": "user", "content": user_prompt}],
        )

        result_text = response.content[0].text
        concept = _parse_json(result_text)

        # BF-9: Sonnet 출력에서 blocks만 추출. grid는 프리셋에서 강제.
        blocks = None
        if concept:
            if "blocks" in concept:
                # 새 형식: {"blocks": [...]}
                blocks = concept["blocks"]
            elif "pages" in concept:
                # 구 형식 호환: {"pages": [{"blocks": [...]}]}
                all_blocks = []
                for p in concept["pages"]:
                    all_blocks.extend(p.get("blocks", []))
                blocks = all_blocks

        if blocks is not None:
            # area명 검증: 프리셋 zone에 없으면 기본 zone으로 매핑
            valid_zones = {z for z in preset["zones"] if z != "header"}
            default_zone = "body" if "body" in valid_zones else next(iter(valid_zones))
            for block in blocks:
                if block.get("area") not in valid_zones:
                    logger.warning(
                        f"zone '{block.get('area')}' → '{default_zone}' 자동 매핑"
                    )
                    block["area"] = default_zone

            logger.info(
                f"[Step B] 블록 매핑 완료: {preset_name}, {len(blocks)}개 블록"
            )
            return {
                "title": analysis.get("title", "슬라이드"),
                "pages": [{
                    "grid_areas": preset["grid_areas"],
                    "grid_columns": preset["grid_columns"],
                    "grid_rows": preset["grid_rows"],
                    "blocks": blocks,
                }],
            }
        else:
            logger.warning("블록 매핑 JSON 파싱 실패. fallback.")

    except Exception as e:
        logger.error(f"Step B 호출 실패: {e}", exc_info=True)

    # fallback: 프리셋 기반 기본 배치
    return _fallback_layout(analysis, preset_name, preset)


def _fallback_layout(
    analysis: dict[str, Any],
    preset_name: str,
    preset: dict[str, Any],
) -> dict[str, Any]:
    """Step B 실패 시 프리셋 기반 기본 배치."""
    blocks = []
    for topic in analysis.get("topics", []):
        role = topic.get("role", "flow")

        if topic.get("detail_target"):
            # detail_target → details-block으로 배치
            if role == "reference" and preset_name == "sidebar-right":
                area = "sidebar"
            else:
                area = "body" if preset_name != "two-column" else "left"
            blocks.append({
                "area": area,
                "type": "details-block",
                "topic_id": topic.get("id", len(blocks) + 1),
                "reason": f"detail_target: {topic.get('title', '')}",
                "size": "medium",
                "char_guide": {"summary_text": 60, "detail_content": 300},
            })
            continue

        if role == "reference" and preset_name == "sidebar-right":
            area = "sidebar"
        elif topic.get("layer") == "conclusion":
            area = "footer"
        else:
            area = "body" if preset_name != "two-column" else "left"

        blocks.append({
            "area": area,
            "type": "topic-header",
            "topic_id": topic.get("id", 0),
            "reason": topic.get("title", ""),
            "size": "medium",
        })

    return {
        "title": analysis.get("title", "슬라이드"),
        "pages": [{
            "grid_areas": preset["grid_areas"],
            "grid_columns": preset["grid_columns"],
            "grid_rows": preset["grid_rows"],
            "blocks": blocks,
        }],
    }


def _parse_json(text: str) -> dict[str, Any] | None:
    """텍스트에서 JSON을 추출한다."""
    patterns = [
        r"```json\s*(.*?)```",
        r"```\s*(.*?)```",
        r"(\{.*\})",
    ]
    for pattern in patterns:
        match = re.search(pattern, text, re.DOTALL)
        if match:
            try:
                return json.loads(match.group(1).strip())
            except json.JSONDecodeError:
                continue
    return None
