# -*- coding: utf-8 -*-
"""
네이버 통합검색에서 아파트 단지의 확인된 정보만 보강한다.

안전 원칙
- 매물 API가 아파트이고 공식 단지명이 있을 때만 검색한다.
- 검색어에는 동/호/층/거래유형을 붙이지 않는다.
- 검색 결과에 같은 단지명의 네이버페이 부동산 영역이 없으면 사용하지 않는다.
- 주소가 양쪽에 있을 때 서로 충돌하면 사용하지 않는다.
- AI 브리핑은 원문 저장/복사 대상이 아니라 Ollama 참고용 근거 텍스트로만 보관한다.
"""

import re
import unicodedata
from datetime import datetime
from urllib.parse import parse_qs, quote_plus, urlparse


APT_TYPE_CODES = {"APT"}
APT_TYPE_NAMES = {"아파트"}


def _text(value):
    value = unicodedata.normalize("NFKC", str(value or ""))
    return re.sub(r"\s+", " ", value).strip()


def _compact(value):
    return re.sub(r"[\s\-_·ㆍ.,()\[\]]+", "", _text(value)).lower()


def _complex_key(value):
    """검색 결과의 '마을' 표기 유무만 다른 동일 단지를 비교한다."""
    return _compact(value).replace("마을", "")


def _first(*values):
    for value in values:
        value = _text(value)
        if value and value.lower() not in {"null", "none", "-", "해당없음"}:
            return value
    return ""


def _article_parts(data):
    data = data if isinstance(data, dict) else {}
    return (
        data.get("articleDetail") or {},
        data.get("articleAddition") or {},
    )


def resolve_official_complex_name(data):
    """제목 추측보다 매물 API의 공식 단지명 필드를 우선한다."""
    detail, addition = _article_parts(data)
    name = _first(
        detail.get("complexName"),
        detail.get("aptName"),
        addition.get("complexName"),
        addition.get("aptName"),
    )
    if not name:
        return ""

    # API 필드에 동이 붙은 예외만 제거한다. 제목 전체를 정규식 추측하지 않는다.
    name = re.sub(r"\s+\d{1,4}\s*동$", "", name).strip()
    return name


def get_article_address(data):
    detail, addition = _article_parts(data)
    complex_detail = data.get("complexDetail") or {}
    return _first(
        data.get("complexResolvedAddress"),
        complex_detail.get("roadAddress"),
        complex_detail.get("address"),
        detail.get("roadAddress"),
        detail.get("exposureAddress"),
        addition.get("exposureAddress"),
    )


def _administrative_region(value):
    """검색어에는 매물/중개업소의 번지·동호수를 넣지 않는다."""
    value = _text(value)
    if not value:
        return ""

    province_short = {
        "서울", "부산", "대구", "인천", "광주", "대전", "울산", "세종",
        "경기", "강원", "충북", "충남", "전북", "전남", "경북", "경남", "제주",
    }
    result = []
    seen = set()
    for raw_token in value.split():
        token = raw_token.strip(",()[]")
        if not token:
            continue
        if token in {"세종", "세종시", "세종특별자치시"}:
            token = "세종특별자치시"
        if token in province_short or re.search(
            r"(?:특별자치도|특별자치시|특별시|광역시|도|시|군|구)$", token
        ):
            if token not in seen:
                result.append(token)
                seen.add(token)
            continue

        locality = re.match(r"^([가-힣A-Za-z0-9]+?(?:읍|면|동))(?:\d.*)?$", token)
        if locality:
            name = locality.group(1)
            if name not in {"상가", "상가동"}:
                if name not in seen:
                    result.append(name)
                    seen.add(name)
            break

        # 도로명이나 번지, 동·호 정보가 시작되면 행정구역 추출을 끝낸다.
        if re.search(r"\d|(?:로|길)$|(?:상가|상가동|호)$", token):
            break
    return _text(" ".join(result))


def is_apartment_article(data):
    detail, addition = _article_parts(data)
    code = _first(
        detail.get("realestateTypeCode"),
        detail.get("articleTypeCode"),
        addition.get("realEstateTypeCode"),
        addition.get("articleRealEstateTypeCode"),
    ).upper()
    name = _first(
        detail.get("realestateTypeName"),
        addition.get("realEstateTypeName"),
        addition.get("articleRealEstateTypeName"),
    )
    return code in APT_TYPE_CODES or name in APT_TYPE_NAMES


def build_decision(data):
    complex_name = resolve_official_complex_name(data)
    article_address = get_article_address(data)
    search_region = _administrative_region(article_address)
    if not is_apartment_article(data):
        return {
            "eligible": False,
            "verified": False,
            "reason": "not_apartment",
            "query": "",
            "complex_name": "",
        }
    if not complex_name:
        return {
            "eligible": False,
            "verified": False,
            "reason": "official_complex_name_missing",
            "query": "",
            "complex_name": "",
        }
    return {
        "eligible": True,
        "verified": False,
        "reason": "pending_search",
        "query": _text(f"{search_region} {complex_name}") if search_region else complex_name,
        "complex_name": complex_name,
        "article_address": article_address,
        "search_region": search_region,
    }


def _address_tokens(value):
    value = _text(value)
    # 시/군/구/동 및 도로명처럼 비교 가치가 있는 토큰만 사용한다.
    tokens = {
        token
        for token in re.findall(r"[가-힣A-Za-z0-9]+(?:시|군|구|읍|면|동|로|길)|\d{1,4}", value)
        if len(token) >= 2
    }
    return {
        token for token in tokens
        if not re.fullmatch(r"상가(?:제?\d+)?동", token)
    }


def addresses_conflict(article_address, search_text):
    # 중개업소 번지·호수가 섞인 원문 전체가 아니라 행정구역까지만 비교한다.
    article_tokens = _address_tokens(_administrative_region(article_address) or article_address)
    search_tokens = _address_tokens(search_text)
    if not article_tokens or not search_tokens:
        return False
    locality = {x for x in article_tokens if re.search(r"(시|군|구|읍|면|동)$", x)}
    return bool(locality) and not bool(locality & search_tokens)


def _extract_assigned_schools(text):
    text = _text(text)
    marker = re.search(
        r"배정\s*학교\s*(.{0,450}?)(?=(?:학군정보|단지정보|매물정보|시세|관리비|$))",
        text,
        flags=re.IGNORECASE,
    )
    if not marker:
        return []

    block = marker.group(1)
    patterns = [
        (r"([가-힣A-Za-z0-9·ㆍ\-]{2,30}초(?:등학교)?)", "초등학교", "elementary"),
        (r"([가-힣A-Za-z0-9·ㆍ\-]{2,30}중(?:학교)?)", "중학교", "middle"),
        (r"([가-힣A-Za-z0-9·ㆍ\-]{2,30}고(?:등학교)?)(?!등학군)", "고등학교", "high"),
    ]
    result = []
    seen = set()
    for pattern, school_type, school_level in patterns:
        for match in re.finditer(pattern, block):
            name = _text(match.group(1))
            # '세종시고등학군'의 '세종시고'처럼 학군 명칭 일부를 학교로 오인하지 않는다.
            if block[match.end():match.end() + 3] == "등학군":
                continue
            key = _compact(name)
            if not key or key in seen:
                continue
            seen.add(key)
            result.append({
                "school_name": name,
                "school_type": school_type,
                "school_level": school_level,
                "distance_text": "",
                "distance_meter": 0,
                "assignment_status": "assigned",
                "source": "naver_pay_realestate",
            })
    return result[:20]


def _school_record(name, href=""):
    """네이버페이 배정학교 링크의 표시명/URL에서 학교 급을 판별한다."""
    name = _text(name)
    if not name or name in {"외", "열기", "접기"}:
        return None

    query = parse_qs(urlparse(href or "").query)
    education = _text((query.get("activeEducations") or [""])[0]).upper()
    if name.endswith(("초", "초등학교")) or education == "ES":
        school_type, school_level = "초등학교", "elementary"
    elif name.endswith(("중", "중학교")):
        school_type, school_level = "중학교", "middle"
    elif name.endswith(("고", "고등학교")):
        school_type, school_level = "고등학교", "high"
    else:
        # MS-HS URL은 중·고가 함께 쓰이므로 표시명으로 구분되지 않으면 저장하지 않는다.
        return None

    return {
        "school_name": name,
        "school_type": school_type,
        "school_level": school_level,
        "distance_text": "",
        "distance_meter": 0,
        "assignment_status": "assigned",
        "source": "naver_pay_realestate",
    }


def _extract_assigned_schools_from_container(container):
    """data-school-fold 안의 실제 학교 링크만 읽어 일반 문장 오탐을 차단한다."""
    if container is None:
        return []

    selectors = (
        "[data-school-fold] a[href*='/schools/'], "
        "[data-school-fold] a[href*='/educations/']"
    )
    result, seen = [], set()
    try:
        links = container.locator(selectors)
        for index in range(min(links.count(), 60)):
            link = links.nth(index)
            try:
                item = _school_record(
                    link.inner_text(timeout=700),
                    link.get_attribute("href") or "",
                )
            except Exception:
                continue
            if not item:
                continue
            key = _compact(item["school_name"])
            if key in seen:
                continue
            seen.add(key)
            result.append(item)
    except Exception:
        return []
    return result[:20]


def _extract_labeled_block(text, labels, stop_labels, limit=2500):
    text = _text(text)
    label_pattern = "|".join(re.escape(x) for x in labels)
    stop_pattern = "|".join(re.escape(x) for x in stop_labels)
    match = re.search(
        rf"(?:{label_pattern})\s*(.{{0,{limit}}}?)(?=(?:{stop_pattern})|$)",
        text,
        flags=re.IGNORECASE,
    )
    return _text(match.group(1)) if match else ""


def _extract_facts(text):
    facts = {}
    rules = {
        "address": (
            r"((?:[가-힣]+(?:특별자치도|특별자치시|특별시|광역시|도)\s+)?"
            r"(?:[가-힣0-9]+(?:시|군|구)\s+){0,2}"
            r"[가-힣0-9]+(?:대로|로|길)\s+\d+(?:-\d+)?)"
        ),
        "households": r"(\d[\d,]*)\s*세대",
        "buildings": r"(\d+)\s*개동",
        "completion": r"((?:19|20)\d{2}[./년]\s*\d{1,2}(?:[./월]\s*\d{1,2}일?)?)",
        "parking": r"(?:주차|총주차대수)[^\d]{0,15}(\d[\d,]*)\s*대",
        "floor_area_ratio": r"용적률\s*(\d+(?:\.\d+)?)\s*%",
        "building_coverage": r"건폐율\s*(\d+(?:\.\d+)?)\s*%",
    }
    for key, pattern in rules.items():
        match = re.search(pattern, text)
        if match:
            facts[key] = _text(match.group(1))
    return facts


def _extract_facts_near_verified_complex(text, complex_name):
    """Find address facts only near occurrences of the verified complex name."""
    text = _text(text)
    complex_name = _text(complex_name)
    if not text or not complex_name:
        return {}

    for match in re.finditer(re.escape(complex_name), text, flags=re.IGNORECASE):
        # Search-result cards normally place their facts after the complex
        # name. Do not look backward, where another result's address may sit.
        start = match.start()
        end = min(len(text), match.end() + 1400)
        segment = text[start:end]
        facts = _extract_facts(segment)
        address = _text(facts.get("address"))
        address_at = segment.find(address) if address else -1
        address_tail = (
            segment[address_at:address_at + len(address) + 80]
            if address_at >= 0 else ""
        )
        if address and not re.search(
            r"상가\s*(?:제?\s*\d+\s*동|동)?\s*제?\s*\d+\s*호",
            address_tail,
        ):
            return facts
    return {}


def _extract_nearby_facilities(text, source="naver_pay_realestate"):
    rules = [
        (r"([가-힣A-Za-z0-9\-]{2,35}(?:역|정류장))", "교통", "transport"),
        (r"([가-힣A-Za-z0-9\-]{2,35}공원)", "공원/녹지", "park"),
        (r"([가-힣A-Za-z0-9\-]{2,35}(?:병원|의원|약국))", "의료", "medical"),
        (r"([가-힣A-Za-z0-9\-]{2,35}(?:마트|백화점|시장))", "쇼핑/편의", "shopping"),
        (r"([가-힣A-Za-z0-9\-]{2,35}(?:도서관|주민센터|행정복지센터))", "공공/문화", "public"),
    ]
    result = []
    seen = set()
    for pattern, facility_type, category in rules:
        for name in re.findall(pattern, _text(text)):
            name = _text(name)
            key = _compact(name)
            if not key or key in seen:
                continue
            seen.add(key)
            result.append({
                "facility_name": name,
                "facility_type": facility_type,
                "facility_category": category,
                "distance_text": "",
                "distance_meter": 0,
                "source": source,
            })

    # AI 브리핑은 고유 상호명 대신 단지 내부 커뮤니티 시설을 나열하는 경우가
    # 많다. 이 값들은 문장 전체를 시설명으로 만들지 않고, 실제로 언급된
    # 시설명만 제한적으로 구조화한다.
    amenity_rules = [
        ("게스트하우스", "커뮤니티", "community"),
        ("사우나", "커뮤니티", "community"),
        ("독서실", "커뮤니티", "community"),
        ("실내수영장", "커뮤니티", "community"),
        ("수영장", "커뮤니티", "community"),
        ("헬스장", "커뮤니티", "community"),
        ("피트니스센터", "커뮤니티", "community"),
        ("골프연습장", "커뮤니티", "community"),
        ("전기자동차 충전소", "생활편의", "convenience"),
        ("전기차 충전소", "생활편의", "convenience"),
    ]
    compact_text = _compact(text)
    for name, facility_type, category in amenity_rules:
        key = _compact(name)
        if key not in compact_text or key in seen:
            continue
        # 실내수영장이 있으면 더 짧은 '수영장' 중복은 만들지 않는다.
        if name == "수영장" and _compact("실내수영장") in seen:
            continue
        seen.add(key)
        result.append({
            "facility_name": name,
            "facility_type": facility_type,
            "facility_category": category,
            "distance_text": "",
            "distance_meter": 0,
            "source": source,
        })
    return result[:20]


def _find_naver_pay_result(page, complex_name):
    compact_name = _complex_key(complex_name)
    # 단지 상세 링크를 먼저 사용한다. AI 브리핑 안의 출처 링크는 후보에서 제외한다.
    candidates = page.locator(
        "a[href*='fin.land.naver.com/complexes/'], "
        "a[href*='new.land.naver.com/complexes/']"
    )
    matches = []
    for index in range(min(candidates.count(), 80)):
        link = candidates.nth(index)
        try:
            text = _text(link.inner_text(timeout=700))
            href = _text(link.get_attribute("href"))
        except Exception:
            continue
        nearby = text
        container = None
        try:
            ancestors = link.locator(
                "xpath=ancestor::*[self::section or self::article or self::li or self::div]"
            )
            scoped_candidates = []
            for depth in range(min(ancestors.count(), 14)):
                candidate = ancestors.nth(depth)
                candidate_text = _text(candidate.inner_text(timeout=700))
                if (
                    compact_name in _complex_key(candidate_text)
                    and (
                        candidate.locator("[data-school-fold]").count() > 0
                        or "배정 학교" in candidate_text
                    )
                ):
                    scoped_candidates.append((len(candidate_text), candidate_text, candidate))
            if scoped_candidates:
                _, nearby, container = min(scoped_candidates, key=lambda item: item[0])
        except Exception:
            pass
        if compact_name and compact_name in _complex_key(nearby):
            matches.append({"text": nearby[:6000], "href": href, "container": container})
    return matches


def _expand_assigned_school_section(page, container):
    """'외 N'으로 접힌 배정학교가 있으면 단지 카드 안에서만 펼친다."""
    scope = container if container is not None else page
    selectors = [
        "button:has-text('외 ')",
        "[role='button']:has-text('외 ')",
        "button[aria-expanded='false']",
    ]
    for selector in selectors:
        try:
            buttons = scope.locator(selector)
            for index in range(min(buttons.count(), 10)):
                button = buttons.nth(index)
                label = _text(button.inner_text(timeout=500))
                aria_label = _text(button.get_attribute("aria-label"))
                nearby = _text(
                    button.locator("xpath=ancestor::*[self::li or self::div][1]").inner_text(timeout=500)
                )
                combined = " ".join((label, aria_label, nearby))
                if "배정" not in combined and not re.search(r"외\s*\d+", combined):
                    continue
                button.click(timeout=1000)
                page.wait_for_timeout(400)
        except Exception:
            continue


def _extract_ai_briefing(body_text):
    briefing = _extract_labeled_block(
        body_text,
        ["AI 브리핑", "AI Briefing"],
        ["함께 많이 찾는", "관련 검색어", "뉴스"],
        limit=9000,
    )
    if not briefing:
        return {"raw_context": "", "nearby": "", "features": ""}

    nearby = _extract_labeled_block(
        briefing,
        ["주변과 특징", "참고할 만한 주변 정보", "주변"],
        ["출처", "함께 많이 찾는", "관련 검색어"],
        limit=3500,
    )
    features = _extract_labeled_block(
        briefing,
        ["주변과 특징", "특징"],
        ["출처", "함께 많이 찾는", "관련 검색어"],
        limit=3500,
    )
    return {
        "raw_context": briefing[:3500],
        "nearby": nearby[:1800],
        "features": features[:1800],
    }


def _extract_ai_briefing_from_page(page):
    """난수 클래스 대신 AI 브리핑의 의미 기반 DOM 속성과 markdown 역할을 사용한다."""
    root_selector = (
        "[data-fender-root][data-block-id*='ai-briefing'], "
        "[data-fender-root]:has(.fds-aib-header-title-text)"
    )
    try:
        page.locator(root_selector).first.wait_for(state="attached", timeout=4000)
    except Exception:
        pass
    roots = page.locator(root_selector)
    print("[AI BRIEFING DOM ROOTS]", roots.count())
    for index in range(min(roots.count(), 5)):
        root = roots.nth(index)
        try:
            payload = root.evaluate(
                """
                (root) => {
                    const clean = (node) => {
                        const clone = node.cloneNode(true);
                        clone.querySelectorAll(
                            '.fds-overlay-chip, [class*="overlay-chip"], button, svg'
                        ).forEach((el) => el.remove());
                        return (clone.innerText || clone.textContent || '')
                            .replace(/\\s+/g, ' ').trim();
                    };
                    const headings = [...root.querySelectorAll(
                        '.fds-markdown-h, [role="heading"], h1, h2, h3, h4'
                    )];
                    const heading = headings.find((el) => {
                        const value = clean(el).replace(/\\s+/g, '');
                        return value.includes('주변과특징')
                            || value.includes('참고할만한주변정보')
                            || value === '주변'
                            || value === '특징';
                    });
                    if (!heading) return null;

                    const items = [];
                    let node = heading.nextElementSibling;
                    while (node && !node.matches(
                        '.fds-markdown-h, [role="heading"], h1, h2, h3, h4'
                    )) {
                        const listItems = [...node.querySelectorAll(
                            '.fds-markdown-li-text, li'
                        )];
                        if (listItems.length) {
                            listItems.forEach((item) => {
                                const value = clean(item);
                                if (value) items.push(value);
                            });
                        } else {
                            const value = clean(node);
                            if (value && !value.startsWith('출처 ')) items.push(value);
                        }
                        node = node.nextElementSibling;
                    }
                    if (!items.length) {
                        const parent = heading.parentElement;
                        const allItems = parent
                            ? [...parent.querySelectorAll('.fds-markdown-li-text, li')]
                            : [];
                        allItems.forEach((item) => {
                            const value = clean(item);
                            if (value) items.push(value);
                        });
                    }
                    return {
                        heading: clean(heading),
                        items,
                        raw: items.join(' ')
                    };
                }
                """
            )
        except Exception as exc:
            print("[AI BRIEFING DOM ERROR]", str(exc)[:300])
            continue
        if not payload or not payload.get("raw"):
            continue

        items = [_text(item) for item in payload.get("items") or [] if _text(item)]
        heading = _text(payload.get("heading"))
        nearby_items = [
            item for item in items
            if re.match(r"(교육|교통|생활|주변|입지|상권|공원|의료)\s*(?:인프라)?\s*:", item)
        ]
        feature_items = [
            item for item in items
            if item not in nearby_items
            or re.match(r"(커뮤니티|단지|시설|특징)\s*(?:및 시설)?\s*:", item)
        ]
        # 실제 검색 화면은 '주변과 특징'이라는 하나의 제목 아래 모든 항목을
        # 함께 제공한다. 이 경우 항목을 억지로 한쪽에서 제외하지 않고,
        # 검증된 동일 문장 묶음을 주변/특징 양쪽의 재작성 근거로 전달한다.
        if "주변" in heading and "특징" in heading:
            nearby_items = nearby_items or items
            feature_items = items
        raw = _text(payload.get("raw"))
        return {
            "raw_context": raw[:3500],
            "nearby": _text(" ".join(nearby_items or items))[:1800],
            "features": _text(" ".join(feature_items))[:1800],
        }
    return {"raw_context": "", "nearby": "", "features": ""}


def _normalize_ai_briefing(ai_briefing, body_text):
    """결합 제목을 보존하고, fallback 경로에서도 특징 근거가 유실되지 않게 한다."""
    ai_briefing = dict(ai_briefing or {})
    raw = _text(ai_briefing.get("raw_context"))
    nearby = _text(ai_briefing.get("nearby"))
    features = _text(ai_briefing.get("features"))
    combined_heading = "주변과특징" in _compact(raw) or "주변과특징" in _compact(body_text)

    if combined_heading:
        combined = nearby or features
        nearby = nearby or combined
        features = features or combined

    return {
        "raw_context": raw,
        "nearby": nearby,
        "features": features,
    }


def collect_naver_search_enrichment(context, data, timeout_ms=20000):
    decision = build_decision(data)
    result = {
        **decision,
        "collected_at": datetime.now().isoformat(timespec="seconds"),
        "source": "naver_search",
        "sources": [],
        "complex_facts": {},
        "assigned_schools": [],
        "nearby_facilities": [],
        "ai_briefing_context": "",
        "ai_briefing_nearby": "",
        "ai_briefing_features": "",
    }
    if not decision["eligible"]:
        return result

    page = context.new_page()
    try:
        url = (
            "https://search.naver.com/search.naver"
            f"?where=nexearch&sm=top_hty&fbm=0&ie=utf8&query={quote_plus(decision['query'])}"
        )
        page.goto(url, wait_until="domcontentloaded", timeout=timeout_ms)
        page.wait_for_timeout(1200)
        body_text = _text(page.locator("body").inner_text(timeout=timeout_ms))
        pay_matches = _find_naver_pay_result(page, decision["complex_name"])

        if not pay_matches:
            result["reason"] = "matching_naver_pay_realestate_not_found"
            return result
        if addresses_conflict(get_article_address(data), body_text):
            result["reason"] = "address_conflict"
            return result

        primary_match = pay_matches[0]
        _expand_assigned_school_section(page, primary_match.get("container"))
        if primary_match.get("container") is not None:
            try:
                pay_card_text = _text(
                    primary_match["container"].inner_text(timeout=timeout_ms)
                )
            except Exception:
                pay_card_text = primary_match.get("text") or ""
        else:
            pay_card_text = primary_match.get("text") or ""

        result["verified"] = True
        result["reason"] = "verified"
        result["sources"].append({
            "type": "naver_pay_realestate",
            "url": primary_match["href"],
        })
        # 네이버페이 부동산 단지 카드 내부만 구조화 정보의 1순위 근거로 사용한다.
        result["complex_facts"] = _extract_facts(pay_card_text)
        if not result["complex_facts"].get("address"):
            page_facts = _extract_facts_near_verified_complex(
                body_text,
                decision["complex_name"],
            )
            if page_facts.get("address"):
                result["complex_facts"].update(page_facts)
                result["sources"].append({
                    "type": "naver_search_verified_complex_context",
                    "usage": "address_fallback",
                })
        result["assigned_schools"] = _extract_assigned_schools_from_container(
            primary_match.get("container")
        )
        if not result["assigned_schools"]:
            result["assigned_schools"] = _extract_assigned_schools(pay_card_text)
        result["nearby_facilities"] = _extract_nearby_facilities(
            pay_card_text,
            source="naver_pay_realestate",
        )

        # AI 브리핑은 '주변'과 '특징'을 분리해 사실 참고 근거로만 전달한다.
        ai_briefing = _extract_ai_briefing_from_page(page)
        if not ai_briefing["raw_context"]:
            ai_briefing = _extract_ai_briefing(body_text)
        ai_briefing = _normalize_ai_briefing(ai_briefing, body_text)
        if ai_briefing["raw_context"]:
            result["ai_briefing_context"] = ai_briefing["raw_context"][:3500]
            result["ai_briefing_nearby"] = ai_briefing["nearby"]
            result["ai_briefing_features"] = ai_briefing["features"]
            result["sources"].append({
                "type": "naver_search_ai_briefing",
                "usage": "fact_reference_only",
            })
            ai_facilities = _extract_nearby_facilities(
                " ".join((ai_briefing["nearby"], ai_briefing["features"])),
                source="naver_search_ai_briefing",
            )
            existing = {_compact(x.get("facility_name")) for x in result["nearby_facilities"]}
            result["nearby_facilities"].extend(
                x for x in ai_facilities
                if _compact(x.get("facility_name")) not in existing
            )

        print("[NAVER PAY CARD FOUND]", decision["complex_name"])
        print("[NAVER PAY CARD TEXT]", pay_card_text[:1200])
        print("[NAVER PAY COMPLEX FACTS]", result["complex_facts"])
        print("[ASSIGNED SCHOOL RAW]", _extract_labeled_block(
            pay_card_text,
            ["배정 학교"],
            ["학군정보", "단지정보", "매물정보", "시세", "관리비"],
            limit=1200,
        ) or "-")
        for school in result["assigned_schools"]:
            print("[ASSIGNED SCHOOL FOUND]", school["school_name"], school["school_type"])
        print(
            "[AI BRIEFING]",
            "nearby=", bool(result["ai_briefing_nearby"]),
            "features=", bool(result["ai_briefing_features"]),
        )
        print("[AI BRIEFING NEARBY]", result["ai_briefing_nearby"][:1200] or "-")
        print("[AI BRIEFING FEATURES]", result["ai_briefing_features"][:1200] or "-")
        print("[AI FACILITY COUNT]", len([
            item for item in result["nearby_facilities"]
            if item.get("source") == "naver_search_ai_briefing"
        ]))
        return result
    except Exception as exc:
        result["reason"] = "search_error"
        result["error"] = str(exc)[:500]
        return result
    finally:
        page.close()
