# -*- coding: utf-8 -*-
"""
STEP107-01 - V2 Complex Resolver Listing Fallback Priority Fix

목적:
- 1순위: 네이버 검색 단지카드에서 소재지/지번/배정학교/complex_no 확보
- 2순위: 네이버 검색 실패 또는 지번 없음이면 기존 매물 수집 데이터에서 주소 확보
- 3순위: 기존 캐시값이 있으면 유지

권장 적용 위치:
  D:/honghee/blog_api/services/naver_search_complex_resolver_v2.py

주의:
- V1 publish_worker.py 수정 금지.
- 기존 services/naver_search_complex_resolver.py 덮어쓰기 금지.
- V2 publish_worker.py 단계가 아니라, 수집/초안 생성 전 주소 보강 단계에서 호출한다.
"""

import json
import os
import re
from dataclasses import dataclass, asdict
from datetime import datetime
from typing import Dict, List, Optional
from urllib.parse import quote_plus

try:
    import pymysql
except Exception:
    pymysql = None

try:
    from playwright.sync_api import sync_playwright
except Exception:
    sync_playwright = None


@dataclass
class ComplexSearchResolveResult:
    ok: bool
    query: str
    complex_name: str = ""
    complex_no: str = ""
    road_address: str = ""
    jibun_address: str = ""
    resolved_address: str = ""
    school_name: str = ""
    school_names: str = ""
    source: str = "naver_search_complex_card"
    fallback_used: bool = False
    fallback_source: str = ""
    error: Optional[str] = None
    raw: Optional[Dict] = None


def normalize_space(text: str) -> str:
    return re.sub(r"\s+", " ", str(text or "")).strip()


def strip_html_tags(html: str) -> str:
    html = re.sub(r"<script\b[^>]*>.*?</script>", " ", html or "", flags=re.I | re.S)
    html = re.sub(r"<style\b[^>]*>.*?</style>", " ", html, flags=re.I | re.S)
    html = re.sub(r"<[^>]+>", " ", html)
    html = html.replace("&nbsp;", " ").replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")
    return normalize_space(html)


def clean_complex_search_query(text: str) -> str:
    q = normalize_space(text)
    q = q.replace("·", " ")
    q = re.sub(r"\([^)]*(주상복합|아파트|오피스텔|도시형생활주택)[^)]*\)", " ", q)
    q = re.sub(r"\b\d+\s*동\b", " ", q)
    q = re.sub(r"\b\d+\s*호\b", " ", q)
    q = re.sub(r"\b\d+\s*층\b", " ", q)
    q = re.sub(r"\b(매매|전세|월세|단기|임대|분양권|상가|토지)\b", " ", q)
    q = re.sub(r"\b\d+\s*억[\d,\s]*(만|만원)?\b", " ", q)
    q = re.sub(r"\b\d{1,3}(,\d{3})+\b", " ", q)
    q = re.sub(r"\s+", " ", q).strip()
    parts = q.split()
    return parts[0].strip() if parts else q


def _extract_complex_no(html: str) -> str:
    m = re.search(r"fin\.land\.naver\.com/complexes/(\d+)", html or "")
    return m.group(1) if m else ""


def _extract_title(html: str) -> str:
    m = re.search(r'<strong[^>]*class=["\'][^"\']*name[^"\']*["\'][^>]*>(.*?)</strong>', html or "", flags=re.I | re.S)
    if m:
        return strip_html_tags(m.group(1))
    m = re.search(r'data-title=["\']([^"\']+)["\']', html or "", flags=re.I)
    return normalize_space(m.group(1)) if m else ""


def _extract_road_address(html: str) -> str:
    addr_spans = re.findall(r'<span[^>]*class=["\'][^"\']*addr[^"\']*["\'][^>]*>(.*?)</span>', html or "", flags=re.I | re.S)
    for raw in addr_spans:
        txt = strip_html_tags(raw)
        if txt and re.search(r"(로|길)\s*\d+", txt) and "동 " not in txt:
            return normalize_space(re.sub(r"\(우\)\s*\d+", "", txt))
    return ""


def infer_region_prefix_from_address(address: str) -> str:
    """
    road_address 예: 세종시 산울2로 10, 부산시 연제구 연산동 ...
    지번이 '산울동 313-14'처럼 동 이하만 나올 때 앞 지역명을 보강한다.
    """
    address = normalize_space(address)
    if not address:
        return ""

    if address.startswith("세종시"):
        return "세종시"

    tokens = address.split()
    if not tokens:
        return ""

    metropolitan = [
        "서울시", "서울특별시", "부산시", "부산광역시", "대구시", "대구광역시",
        "인천시", "인천광역시", "광주시", "광주광역시", "대전시", "대전광역시",
        "울산시", "울산광역시"
    ]

    if tokens[0] in metropolitan:
        if len(tokens) >= 2 and (tokens[1].endswith("구") or tokens[1].endswith("군")):
            return f"{tokens[0]} {tokens[1]}"
        return tokens[0]

    if tokens[0].endswith("도"):
        if len(tokens) >= 2 and (tokens[1].endswith("시") or tokens[1].endswith("군")):
            if len(tokens) >= 3 and tokens[2].endswith("구"):
                return f"{tokens[0]} {tokens[1]} {tokens[2]}"
            return f"{tokens[0]} {tokens[1]}"
        return tokens[0]

    if tokens[0].endswith("시") or tokens[0].endswith("군"):
        if len(tokens) >= 2 and tokens[1].endswith("구"):
            return f"{tokens[0]} {tokens[1]}"
        return tokens[0]

    return ""


def _extract_jibun_address(html: str, region_hint: str = "") -> str:
    pattern = (
        r'<span[^>]*class=["\'][^"\']*label_address[^"\']*["\'][^>]*>\s*지번\s*</span>\s*'
        r'<span[^>]*class=["\'][^"\']*addr[^"\']*["\'][^>]*>(.*?)</span>'
    )
    m = re.search(pattern, html or "", flags=re.I | re.S)
    jibun = strip_html_tags(m.group(1)) if m else ""

    if not jibun:
        addr_spans = re.findall(r'<span[^>]*class=["\'][^"\']*addr[^"\']*["\'][^>]*>(.*?)</span>', html or "", flags=re.I | re.S)
        for raw in addr_spans:
            txt = strip_html_tags(raw)
            if re.search(r"(동|리|가)\s*\d", txt):
                jibun = txt
                break

    if not jibun:
        return ""

    jibun = normalize_space(jibun)
    region_hint = normalize_space(region_hint)

    if re.search(r"^(서울|부산|대구|인천|광주|대전|울산|세종|경기|강원|충북|충남|전북|전남|경북|경남|제주)", jibun):
        return jibun

    if region_hint:
        prefix = re.sub(r"^(세종시)\s+\1\b", r"\1", region_hint)
        return normalize_space(f"{prefix} {jibun}")

    return jibun


def _extract_school_names(html: str) -> str:
    m = re.search(r"배정학교\s*-\s*<a[^>]*>(.*?)</a>", html or "", flags=re.I | re.S)
    if m:
        return normalize_space(strip_html_tags(m.group(1)))
    text = strip_html_tags(html or "")
    m = re.search(r"배정학교\s*-\s*([^주소면적]+?)(?:주소|면적|$)", text)
    if m:
        return normalize_space(m.group(1))
    return ""


def _extract_card_html(page_html: str, query: str = "") -> str:
    html = page_html or ""
    idx = html.find("content_group type_solo")
    if idx < 0:
        idx = html.find("fin.land.naver.com/complexes/")
    if idx < 0:
        return ""
    start = max(0, html.rfind("<div", 0, idx))
    end_candidates = [
        html.find("content_group", idx + 20),
        html.find("api_subject_bx", idx + 20),
        html.find("sp_nland", idx + 20),
    ]
    end_candidates = [x for x in end_candidates if x > idx]
    end = min(end_candidates) if end_candidates else min(len(html), idx + 30000)
    return html[start:end]


def parse_naver_search_complex_card_html(page_or_card_html: str, query: str = "", region_hint: str = "") -> ComplexSearchResolveResult:
    card_html = _extract_card_html(page_or_card_html, query=query) or page_or_card_html or ""
    if not card_html:
        return ComplexSearchResolveResult(ok=False, query=query, error="complex_card_not_found")

    complex_no = _extract_complex_no(card_html)
    title = _extract_title(card_html)
    road_address = _extract_road_address(card_html)
    effective_region_hint = region_hint or infer_region_prefix_from_address(road_address)
    jibun_address = _extract_jibun_address(card_html, region_hint=effective_region_hint)
    school_names = _extract_school_names(card_html)
    resolved = jibun_address or road_address

    ok = bool(complex_no or resolved)
    return ComplexSearchResolveResult(
        ok=ok,
        query=query,
        complex_name=title,
        complex_no=complex_no,
        road_address=road_address,
        jibun_address=jibun_address,
        resolved_address=resolved,
        school_name=school_names.split(",")[0].strip() if school_names else "",
        school_names=school_names,
        source="naver_search_complex_card",
        error=None if ok else "complex_card_parse_failed",
        raw={"card_text": strip_html_tags(card_html)[:2000]},
    )


def extract_listing_fallback_address(listing_data: Optional[Dict], region_hint: str = "") -> Dict:
    """
    네이버 검색 실패 시 기존 매물 수집 데이터에서 주소 후보를 찾는다.

    STEP107-02:
    중개사무소 주소가 매물 소재지로 들어가는 문제를 막기 위해,
    경로 기반 우선순위를 강제한다.

    우선순위:
    1) source_json.detail.complex_resolved_address / article_address / exposure_address / address
    2) raw_json.articleDetail.exposureAddress / roadAddress / jibunAddress
    3) detail/source의 일반 매물 주소
    4) 최후: articleRealtor / office / realtor 주소
    """
    listing_data = listing_data or {}
    candidates = []

    def norm_path(path: str) -> str:
        return re.sub(r"[^a-z0-9_\\.]", "", str(path or "").lower())

    def normalized_address(txt):
        txt = normalize_space(txt)
        txt = re.sub(r"^(세종시)\s+\1\s+", r"\1 ", txt)
        txt = re.sub(r"^(세종특별자치시)\s+세종시\s+", r"\1 ", txt)
        txt = txt.replace("세종시 세종시 ", "세종시 ")
        return txt

    def looks_like_address(txt):
        txt = normalize_space(txt)
        if not txt:
            return False
        if "http://" in txt or "https://" in txt or "<" in txt or ">" in txt:
            return False
        if len(txt) < 4:
            return False
        return bool(re.search(r"(서울|부산|대구|인천|광주|대전|울산|세종|경기|강원|충북|충남|전북|전남|경북|경남|제주|[가-힣]+시|[가-힣]+군|[가-힣]+구|[가-힣]+동|[가-힣]+리)", txt))

    def walk(obj, path=""):
        if isinstance(obj, dict):
            for k, v in obj.items():
                key = str(k or "")
                p = f"{path}.{key}" if path else key
                if isinstance(v, str):
                    txt = normalized_address(v)
                    if looks_like_address(txt):
                        candidates.append((p, txt))
                else:
                    walk(v, p)
        elif isinstance(obj, list):
            for i, v in enumerate(obj):
                walk(v, f"{path}[{i}]")

    walk(listing_data)

    # 중복 제거
    deduped = []
    seen = set()
    for path, txt in candidates:
        key = (norm_path(path), txt)
        if key in seen:
            continue
        seen.add(key)
        deduped.append((path, txt))
    candidates = deduped

    def is_office_path(path):
        p = norm_path(path)
        return any(x in p for x in [
            "articlerealtor",
            "realtor_info",
            "realtor.address",
            "realtor_address",
            "office_address",
            "office_detail_address",
            "broker",
            "agency",
            "agent",
        ])

    preferred_path_patterns = [
        "v2_preferred_property_address",
        "source_json.detail.complex_resolved_address",
        "source_json.detail.article_address",
        "source_json.detail.exposure_address",
        "source_json.detail.address",
        "detail.complex_resolved_address",
        "detail.article_address",
        "detail.exposure_address",
        "detail.address",
        "complex_resolved_address",
        "article_address",
        "exposure_address",
        "raw_json.articledetail.exposureaddress",
        "raw_json.articledetail.roadaddress",
        "raw_json.articledetail.jibunaddress",
        "articledetail.exposureaddress",
        "articledetail.roadaddress",
        "articledetail.jibunaddress",
    ]

    def preferred_score(path):
        p = norm_path(path)
        for idx, pattern in enumerate(preferred_path_patterns):
            pp = norm_path(pattern)
            if pp in p or p.endswith(pp):
                return 1000 - idx
        if is_office_path(path):
            return -100
        if "address" in p or "addr" in p:
            return 10
        return 0

    def has_bunji_number(txt):
        return bool(re.search(r"\d", normalize_space(txt)))

    sorted_candidates = sorted(
        candidates,
        key=lambda item: (preferred_score(item[0]), 1 if has_bunji_number(item[1]) else 0, len(item[1])),
        reverse=True,
    )

    for path, txt in sorted_candidates:
        if preferred_score(path) > 0 and not is_office_path(path):
            return {
                "ok": True,
                "address": txt,
                "source": f"listing_fallback:{path}",
                "candidates": candidates[:40],
            }

    # 정말 매물 주소가 없을 때만 중개사 주소 사용
    for path, txt in sorted_candidates:
        if is_office_path(path):
            return {
                "ok": True,
                "address": txt,
                "source": f"listing_fallback:{path}",
                "candidates": candidates[:40],
            }

    return {
        "ok": False,
        "address": "",
        "source": "listing_fallback:none",
        "candidates": candidates[:40],
    }


def merge_with_listing_fallback(
    search_result: ComplexSearchResolveResult,
    listing_data: Optional[Dict] = None,
    region_hint: str = "",
) -> ComplexSearchResolveResult:
    """
    핵심 정책:
    - 네이버 검색 결과에 resolved_address가 있으면 무조건 1순위 사용
    - 네이버 검색이 실패했거나 주소가 비어 있을 때만 매물 수집 fallback 사용
    """
    if search_result and search_result.ok and search_result.resolved_address:
        return search_result

    fallback = extract_listing_fallback_address(listing_data, region_hint=region_hint)
    if fallback.get("ok"):
        base_raw = (search_result.raw if search_result else {}) or {}
        base_raw["listing_fallback"] = fallback

        return ComplexSearchResolveResult(
            ok=True,
            query=search_result.query if search_result else "",
            complex_name=search_result.complex_name if search_result else "",
            complex_no=search_result.complex_no if search_result else "",
            road_address=search_result.road_address if search_result else "",
            jibun_address=fallback.get("address", ""),
            resolved_address=fallback.get("address", ""),
            school_name=search_result.school_name if search_result else "",
            school_names=search_result.school_names if search_result else "",
            source="listing_fallback_after_search_fail",
            fallback_used=True,
            fallback_source=fallback.get("source", ""),
            error=None,
            raw=base_raw,
        )

    return search_result


def search_naver_complex_card_html(query: str, headless: bool = True, timeout_ms: int = 12000) -> str:
    if sync_playwright is None:
        raise RuntimeError("playwright is not installed")

    url = "https://search.naver.com/search.naver?query=" + quote_plus(query)
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=headless)
        page = browser.new_page()
        page.goto(url, wait_until="domcontentloaded", timeout=timeout_ms)
        try:
            page.wait_for_selector("text=배정학교", timeout=timeout_ms)
        except Exception:
            try:
                page.wait_for_selector("text=지번", timeout=5000)
            except Exception:
                pass
        html = page.content()
        browser.close()
        return html



def build_complex_search_query_variants(clean_name: str, region_hint: str = "") -> list:
    """
    STEP107-05:
    네이버 검색 단지카드가 단일 검색어에서 안 잡히는 경우를 대비해
    단지명 변형 검색어를 여러 개 시도한다.

    예:
      해밀1단지마스터힐스
      해밀마을1단지마스터힐스
      해밀마을1단지
    """
    clean_name = normalize_space(clean_name)
    region_hint = normalize_space(region_hint)

    variants = []

    def add(q):
        q = normalize_space(q)
        if q and q not in variants:
            variants.append(q)

    region_short = region_hint
    region_short = region_short.replace("세종특별자치시", "세종시").strip()

    # 기본 검색은 지역명 없는 단지명을 먼저 사용한다.
    # 지역명이 너무 넓으면 오히려 단지카드가 안 잡히는 경우가 있다.
    add(clean_name)
    add(f"{clean_name} 아파트")

    if region_short:
        add(f"{clean_name} {region_short}")
        add(f"{clean_name} {region_short} 아파트")

    # 해밀1단지 → 해밀마을1단지 변형
    m = re.search(r"해밀\s*(\d+)단지(.+)?", clean_name)
    if m:
        num = m.group(1)
        tail = normalize_space(m.group(2) or "")
        add(f"해밀마을{num}단지{tail}")
        add(f"해밀마을{num}단지{tail} 아파트")
        add(f"해밀마을{num}단지")
        add(f"해밀마을{num}단지 아파트")
        if region_short:
            add(f"해밀마을{num}단지{tail} {region_short}")
            add(f"해밀마을{num}단지 {region_short}")

    # 마스터힐스가 포함된 경우 축약 검색
    if "마스터힐스" in clean_name:
        add("해밀마을1단지마스터힐스")
        add("해밀마을1단지마스터힐스 아파트")
        add("해밀마을1단지")
        add("해밀마을1단지 아파트")

    return variants[:10]



def resolve_complex_address(
    complex_name: str,
    region_hint: str = "",
    listing_data: Optional[Dict] = None,
    headless: bool = True,
    timeout_ms: int = 12000,
) -> ComplexSearchResolveResult:
    clean_name = clean_complex_search_query(complex_name)

    if not clean_name:
        search_result = ComplexSearchResolveResult(ok=False, query="", error="empty_query")
        return merge_with_listing_fallback(search_result, listing_data=listing_data, region_hint=region_hint)

    queries = build_complex_search_query_variants(clean_name, region_hint=region_hint)

    last_result = None

    for query in queries:
        try:
            print("[V2 COMPLEX SEARCH TRY]", query)
            html = search_naver_complex_card_html(query, headless=headless, timeout_ms=timeout_ms)
            search_result = parse_naver_search_complex_card_html(html, query=query, region_hint=region_hint)
            last_result = search_result

            if search_result and search_result.ok and (
                search_result.resolved_address
                or search_result.jibun_address
                or search_result.road_address
                or search_result.complex_no
            ):
                print(
                    "[V2 COMPLEX SEARCH HIT]",
                    query,
                    search_result.complex_name,
                    search_result.resolved_address or search_result.jibun_address or search_result.road_address,
                )
                return merge_with_listing_fallback(search_result, listing_data=listing_data, region_hint=region_hint)

        except Exception as e:
            last_result = ComplexSearchResolveResult(ok=False, query=query, error=f"search_failed:{e}")
            print("[V2 COMPLEX SEARCH FAIL]", query, e)

    if last_result is None:
        last_result = ComplexSearchResolveResult(ok=False, query=normalize_space(f"{clean_name} {region_hint}".strip()), error="search_no_result")

    return merge_with_listing_fallback(last_result, listing_data=listing_data, region_hint=region_hint)


# Backward compatible alias
resolve_complex_from_naver_search = resolve_complex_address


def get_db_conn_from_env():
    if pymysql is None:
        raise RuntimeError("pymysql is not installed")
    return pymysql.connect(
        host=os.getenv("BLOG_DB_HOST", "127.0.0.1"),
        port=int(os.getenv("BLOG_DB_PORT", "3306")),
        user=os.getenv("BLOG_DB_USER", "root"),
        password=os.getenv("BLOG_DB_PASSWORD", ""),
        database=os.getenv("BLOG_DB_NAME", "pjjh030551"),
        charset="utf8mb4",
        autocommit=False,
        cursorclass=pymysql.cursors.DictCursor,
    )


def get_table_columns(conn, table_name: str) -> List[str]:
    with conn.cursor() as cur:
        cur.execute(f"SHOW COLUMNS FROM `{table_name}`")
        return [r["Field"] for r in cur.fetchall()]


def upsert_complex_address_cache(conn, region_name: str, complex_name: str, result: ComplexSearchResolveResult) -> Dict:
    table = "blog_complex_address_cache"
    columns = get_table_columns(conn, table)

    values = {
        "region_name": region_name,
        "complex_name": clean_complex_search_query(complex_name) or complex_name,
        "resolved_address": result.resolved_address,
        "road_address": result.road_address,
        "jibun_address": result.jibun_address,
        "complex_no": result.complex_no,
        "school_name": result.school_name,
        "school_names": result.school_names,
        "source": result.source,
        "last_checked_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        "meta_text": json.dumps(asdict(result), ensure_ascii=False, default=str),
    }

    insert_values = {k: v for k, v in values.items() if k in columns}
    if not insert_values:
        return {"ok": False, "error": "no_matching_columns"}

    fields = list(insert_values.keys())
    placeholders = ", ".join(["%s"] * len(fields))
    field_sql = ", ".join(f"`{f}`" for f in fields)
    update_sql = ", ".join(f"`{f}`=VALUES(`{f}`)" for f in fields if f not in ("id", "region_name", "complex_name"))

    sql = f"""
        INSERT INTO `{table}` ({field_sql})
        VALUES ({placeholders})
        ON DUPLICATE KEY UPDATE {update_sql}
    """
    with conn.cursor() as cur:
        cur.execute(sql, [insert_values[f] for f in fields])
    conn.commit()
    return {"ok": True, "table": table, "values": insert_values}


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument("--query", required=True)
    parser.add_argument("--region", default="")
    parser.add_argument("--html-file", default="")
    parser.add_argument("--listing-json", default="")
    parser.add_argument("--save", action="store_true")
    parser.add_argument("--headless", action="store_true")
    args = parser.parse_args()

    listing_data = {}
    if args.listing_json:
        listing_data = json.loads(open(args.listing_json, "r", encoding="utf-8").read())

    if args.html_file:
        html = open(args.html_file, "r", encoding="utf-8").read()
        search_result = parse_naver_search_complex_card_html(html, query=args.query, region_hint=args.region)
        result = merge_with_listing_fallback(search_result, listing_data=listing_data, region_hint=args.region)
    else:
        result = resolve_complex_address(args.query, region_hint=args.region, listing_data=listing_data, headless=args.headless)

    print(json.dumps(asdict(result), ensure_ascii=False, indent=2))

    if args.save:
        conn = get_db_conn_from_env()
        try:
            saved = upsert_complex_address_cache(conn, args.region, args.query, result)
            print(json.dumps(saved, ensure_ascii=False, indent=2))
        finally:
            conn.close()
