# -*- coding: utf-8 -*-

import random
import re
import html
import json
from datetime import datetime


LAYOUT_TYPES = {
    "story_first": "스토리 중심형",
    "photo_first": "사진 강조형",
    "summary_first": "요약 정보형",
    "gallery_story": "갤러리 스토리형",
    "price_focus": "실거래가 분석형",
    "location_focus": "입지 분석형",
    "recommendation_focus": "추천 포인트형",
    "magazine_style": "매거진형",
    "interview_style": "인터뷰형",
    "checklist_style": "체크리스트형",
    "quiet_luxury": "조용한 럭셔리형",
    "short_review_style": "실거주 후기형",
    "small_image_card": "소형 이미지 카드형",
    "wide_visual": "와이드 비주얼형",
    "school_focus": "학군 강조형",
    "transport_focus": "교통 강조형",
    "family_recommend": "가족 추천형",
    "single_life": "1인 가구형",
    "investment_focus": "투자 분석형",
    "photo_grid": "사진 그리드형",
    "minimal_modern": "미니멀 모던형",
    "storytelling": "스토리텔링형",
    "local_life": "생활권 소개형",
    "compare_market": "시세 비교형",
    "broker_expert": "전문 중개사형",
    "real_estate_news": "부동산 뉴스형",
    "premium_report": "프리미엄 리포트형",
    "villa_focus": "빌라 특화형",
    "office_focus": "사무실 특화형",
}


def esc(value):
    return html.escape(str(value or "").strip())


_PROPERTY_TYPE_DISPLAY_MAP = {"A01": "아파트", "APT": "아파트"}
_TRADE_TYPE_DISPLAY_MAP = {"A1": "매매", "B1": "전세", "B2": "월세"}


def _display_label(value, mapping):
    text = str(value or "").strip()
    if not text:
        return ""
    mapped = mapping.get(text.upper())
    if mapped:
        return mapped
    if re.fullmatch(r"[A-Z]+\d+", text.upper()):
        return ""
    return text


def collapse_duplicate_address_segments(value):
    """세종시 세종시 및 대전시 유성구 대전시 유성구 형태를 정리한다."""
    tokens = str(value or "").strip().split()
    changed = True
    while changed and len(tokens) >= 2:
        changed = False
        for span in range(min(5, len(tokens) // 2), 0, -1):
            i = 0
            while i + (span * 2) <= len(tokens):
                if tokens[i:i + span] == tokens[i + span:i + (span * 2)]:
                    del tokens[i + span:i + (span * 2)]
                    changed = True
                    continue
                i += 1
    return " ".join(tokens).strip()


def normalize_detail_display_types(detail):
    """렌더링 직전 최종 방어: A01/A1 같은 원시 코드를 한글 명칭으로 바꾼다."""
    detail = dict(detail or {})
    raw = detail.get("raw_json")
    if not isinstance(raw, dict):
        try:
            raw = json.loads(raw or "{}")
        except Exception:
            raw = {}
    article_detail = raw.get("articleDetail") or {}
    article_addition = raw.get("articleAddition") or {}

    property_candidates = [
        detail.get("real_estate_type"), detail.get("real_estate_type_name"),
        article_detail.get("realestateTypeName"), article_addition.get("articleRealEstateTypeName"),
        article_addition.get("realEstateTypeName"), article_detail.get("articleTypeCode"),
    ]
    trade_candidates = [
        detail.get("trade_type"), article_detail.get("tradeTypeName"),
        article_addition.get("tradeTypeName"),
    ]
    property_label = next((v for v in (_display_label(x, _PROPERTY_TYPE_DISPLAY_MAP) for x in property_candidates) if v), "")
    trade_label = next((v for v in (_display_label(x, _TRADE_TYPE_DISPLAY_MAP) for x in trade_candidates) if v), "")
    if property_label:
        detail["real_estate_type"] = property_label
        detail["real_estate_type_name"] = property_label
    if trade_label:
        detail["trade_type"] = trade_label
    for key in (
        "address", "road_address", "article_address", "exposure_address",
        "complex_resolved_address", "region_name", "region",
    ):
        if detail.get(key):
            detail[key] = collapse_duplicate_address_segments(detail.get(key))
    return detail




def is_probably_email(value):
    value = str(value or "").strip()
    return bool(re.search(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", value))


def clean_license_number(value):
    value = str(value or "").strip()

    if not value:
        return ""

    if is_probably_email(value):
        return ""

    lowered = value.lower()
    if "naver.com" in lowered or "http://" in lowered or "https://" in lowered:
        return ""

    if not re.search(r"\d", value):
        return ""

    return value[:80]

def nl2br(value):
    text = esc(value)
    if not text:
        return ""

    return text.replace("\n", "<br>")


DIRECTION_MAP = {
    "E": "동향",
    "W": "서향",
    "S": "남향",
    "N": "북향",
    "ES": "동남향",
    "SE": "남동향",
    "WS": "서남향",
    "SW": "남서향",
    "EN": "동북향",
    "NE": "북동향",
    "WN": "서북향",
    "NW": "북서향",
}


def display_direction(detail):
    value = (
        detail.get("direction")
        or detail.get("direction_code")
        or ""
    )

    value = str(value or "").strip()

    if not value:
        return ""

    return DIRECTION_MAP.get(
        value.upper(),
        value
    )



def format_floor_display(value):
    """
    9/16 같은 기계식 층수 표기를 사람이 읽기 좋은 형태로 바꾼다.
    """
    value = str(value or "").strip()

    if not value:
        return ""

    m = re.match(r"^\s*(\d+)\s*/\s*(\d+)\s*$", value)
    if m:
        return f"{m.group(1)}층 / 총 {m.group(2)}층"

    m = re.match(r"^\s*(\d+)층\s*/\s*(\d+)층\s*$", value)
    if m:
        return f"{m.group(1)}층 / 총 {m.group(2)}층"

    return value.replace("층층", "층")


def pick_layout_type():
    return random.choice(list(LAYOUT_TYPES.keys()))


def get_layout_display_name(layout_type):
    return LAYOUT_TYPES.get(layout_type, layout_type)


def image_url(img):
    if not img:
        return ""

    url = (
        img.get("image_url")
        or img.get("public_url")
        or img.get("local_path")
        or ""
    )

    if is_bad_image_url(url):
        return ""

    return url


def is_bad_image_url(url):
    url = str(url or "").strip().lower()

    if not url:
        return True

    bad_keywords = [
        "ssl.pstatic.net/static/m/land/img/og_land",
        "ssl.pstatic.net/static.land/static/service",
        "thmb_detail",
        "sprite",
        "icon",
        "logo",
        "blank",
        "noimage",
        "no_image",
        "favicon",
        "button",
    ]

    if any(x in url for x in bad_keywords):
        return True

    small_types = [
        "type=m70", "type=m95", "type=m100", "type=m120",
        "type=m140", "type=m196", "type=m200", "type=m240",
    ]

    if any(x in url for x in small_types):
        return True

    if "jpgtype=" in url or "jpegtype=" in url or "pngtype=" in url:
        return True

    if ".gif" in url:
        return True

    return False




def is_header_image_record(img):
    """
    자동 생성 대표이미지는 본문 최상단에 별도로 삽입된다.
    따라서 일반 갤러리/추가 참고 이미지/hero 후보에서는 제외한다.
    """
    if not img:
        return False

    category = str(img.get("image_category") or "").lower()
    image_type = str(img.get("image_type") or "").lower()
    asset_type = str(img.get("asset_type") or "").lower()
    file_name = str(img.get("file_name") or img.get("filename") or "").lower()
    url = str(
        img.get("image_url")
        or img.get("public_url")
        or img.get("local_path")
        or ""
    ).lower()

    raw = " ".join([category, image_type, asset_type, file_name, url])

    if category in ["header_image", "representative_image", "blog_header"]:
        return True

    if image_type in ["header_image", "representative_image", "blog_header"]:
        return True

    if asset_type in ["header_image", "representative_image", "blog_header"]:
        return True

    if "/mybox/generated/header_images/" in raw:
        return True

    if "header_apt_basic" in raw or "header_" in file_name:
        return True

    return False


def is_realtor_image_record(img):
    """
    중개사/대표자 프로필 이미지는 상담 및 중개사 안내 블록에서만 사용한다.
    일반 갤러리/추가 참고 이미지에는 노출하지 않는다.
    """
    if not img:
        return False

    category = str(img.get("image_category") or "").lower()
    image_type = str(img.get("image_type") or "").lower()
    file_name = str(img.get("file_name") or img.get("filename") or "").lower()
    url = str(
        img.get("image_url")
        or img.get("public_url")
        or img.get("local_path")
        or ""
    ).lower()

    raw = " ".join([category, image_type, file_name, url])

    if category in ["realtor", "broker", "profile", "agent"]:
        return True

    if image_type in ["realtor", "broker", "profile", "agent"]:
        return True

    if "rltr_profile" in raw:
        return True

    if "realtor" in raw or "broker" in raw:
        return True

    return False


def is_gallery_excluded_image(img):
    return is_header_image_record(img) or is_realtor_image_record(img)


def classify_image_size(img):
    width = int(img.get("width") or 0)

    if width >= 900:
        return "large"

    if width >= 500:
        return "medium"

    return "small"


def pick_hero_image(images):
    """
    안정형 대표 이미지 선택.
    1순위: 실제 매물 사진(article/inside)
    2순위: 단지/외경 사진(building/outside/view)
    3순위: 기타 안전 이미지

    평면도, 중개사 프로필, 배너, 작은 썸네일은 대표 이미지에서 제외합니다.
    """
    if not images:
        return None

    inside_candidates = []
    building_candidates = []
    other_candidates = []

    for img in images:
        if not img:
            continue

        if is_gallery_excluded_image(img):
            continue

        url = image_url(img)
        if not url:
            continue

        category = str(img.get("image_category") or "").lower()
        image_type = str(img.get("image_type") or "").lower()
        file_name = str(img.get("file_name") or "").lower()
        raw = " ".join([category, image_type, file_name, url.lower()])

        if category == "realtor" or image_type == "etc":
            continue

        if is_floorplan_image(img):
            continue

        if category in ["inside", "article"] or image_type in ["inside", "main"]:
            inside_candidates.append(img)
            continue

        if category in ["building", "outside", "view", "complex", "exterior"]:
            building_candidates.append(img)
            continue

        if any(k in raw for k in ["building", "outside", "view", "complex", "exterior", "apt_realimage", "단지", "외관", "전경"]):
            building_candidates.append(img)
            continue

        other_candidates.append(img)

    for group in [inside_candidates, building_candidates, other_candidates]:
        if group:
            return group[0]

    return None

def filter_content_images(images):
    result = []

    bad_words = [
        "logo",
        "icon",
        "sprite",
        "favicon",
        "og_400x400",
        "og_1125x570",
        "profile_default",
        "blank",
        "error",
        "button",
    ]

    for img in images or []:
        if is_gallery_excluded_image(img):
            continue

        url = image_url(img).lower()

        if not url:
            continue

        if any(word in url for word in bad_words):
            continue

        result.append(img)

    return result


def filter_realtor_images(images):
    result = []

    for img in images or []:
        if is_header_image_record(img):
            continue

        url = image_url(img)

        if is_realtor_image_record(img) and url:
            result.append(img)

    return result


def p(text):
    if not text:
        return ""

    return f"""
<p style="font-size:16px; line-height:1.9; color:#333; margin:16px 0;">
  {esc(text)}
</p>
""".strip()


def h2(text):
    return f"""
<h2 style="font-size:22px; line-height:1.5; margin:34px 0 14px; color:#222; font-weight:700;">
  {esc(text)}
</h2>
""".strip()


def img_tag(url, alt="", width="100%", img=None):
    if not url:
        return ""

    width_px = int((img or {}).get("width") or 0)
    height_px = int((img or {}).get("height") or 0)

    image_category = str((img or {}).get("image_category") or "").lower()
    image_type = str((img or {}).get("image_type") or "").lower()

    if image_category == "realtor":
        return f"""
<div style="margin:18px 0; display:flex; justify-content:center;">
  <img src="{esc(url)}" alt="{esc(alt)}"
    style="width:120px; height:120px; object-fit:cover; border-radius:50%; border:2px solid #e5e7eb; background:#fff; display:block;">
</div>
""".strip()

    if (
        width_px > 0
        and height_px > 0
        and width_px <= 120
        and height_px <= 120
    ):
        return f"""
<div style="margin:14px 0; display:flex; justify-content:center;">
  <img src="{esc(url)}" alt="{esc(alt)}"
    style="width:auto; max-width:180px; max-height:180px; border-radius:12px; display:block; border:1px solid #e5e7eb; background:#fff; object-fit:contain; padding:4px;">
</div>
""".strip()

    if (
        "floor" in image_type
        or "plan" in image_type
        or image_category == "floorplan"
    ):
        return f"""
<div style="margin:24px 0; padding:20px; background:#fafafa; border:1px solid #e5e7eb; border-radius:18px;">
  <img src="{esc(url)}" alt="{esc(alt)}"
    style="width:100%; height:auto; max-width:760px; margin:0 auto; display:block; object-fit:contain; border-radius:12px; background:#fff;">
</div>
""".strip()

    return f"""
<div style="margin:28px 0;">
  <img src="{esc(url)}" alt="{esc(alt)}"
    style="width:100%; height:auto; max-width:900px; margin:0 auto; border-radius:18px; display:block; object-fit:cover; box-shadow:0 4px 20px rgba(0,0,0,0.08);">
</div>
""".strip()

def build_image_caption(img):

    image_type = str(
        img.get("image_type") or ""
    ).lower()

    image_category = str(
        img.get("image_category") or ""
    ).lower()

    file_name = str(
        img.get("file_name") or ""
    ).lower()

    raw = " ".join([
        image_type,
        image_category,
        file_name,
    ])

    living_room_pool = [
        "거실 공간과 채광 방향을 함께 확인해보시면 좋습니다.",
        "거실 폭과 개방감을 참고해보실 수 있는 사진입니다.",
        "실제 방문 시 체감 공간이 더 잘 느껴지는 구조입니다.",
        "창 방향과 채광 흐름을 함께 체크해보시면 좋습니다.",
    ]

    kitchen_pool = [
        "주방 동선과 수납 구성을 함께 참고해보시면 좋습니다.",
        "실사용 관점에서 주방 활용도를 확인하기 좋은 구조입니다.",
        "조리 공간과 동선 배치를 함께 체크해보시면 좋습니다.",
    ]

    room_pool = [
        "방 크기와 창 위치를 함께 확인해보시면 좋습니다.",
        "실제 가구 배치 시 참고가 될 수 있는 공간입니다.",
        "침실 활용도를 함께 생각해보시면 좋습니다.",
    ]

    bathroom_pool = [
        "욕실 컨디션과 관리 상태를 참고해보실 수 있습니다.",
        "실사용 시 동선과 구조를 함께 확인해보시면 좋습니다.",
    ]

    exterior_pool = [
        "건물 외관과 단지 분위기를 참고해보실 수 있습니다.",
        "실제 방문 시 주변 환경도 함께 체크해보시면 좋습니다.",
        "단지 배치와 외관 컨디션을 참고하기 좋은 사진입니다.",
    ]

    floorplan_pool = [
        "전체 공간 구조를 확인할 수 있는 도면입니다.",
        "실사용 동선을 미리 참고해보실 수 있습니다.",
        "방과 거실 배치를 함께 확인해보시면 좋습니다.",
    ]

    general_pool = [
        "실제 방문 전 참고용으로 확인해보시면 좋습니다.",
        "공간 활용도를 함께 체크해보시면 좋습니다.",
        "현장 분위기를 참고하실 수 있는 사진입니다.",
    ]

    if any(x in raw for x in [
        "living",
        "livingroom",
        "거실",
    ]):
        return random.choice(living_room_pool)

    if any(x in raw for x in [
        "kitchen",
        "주방",
    ]):
        return random.choice(kitchen_pool)

    if any(x in raw for x in [
        "room",
        "bed",
        "침실",
        "방",
    ]):
        return random.choice(room_pool)

    if any(x in raw for x in [
        "bath",
        "toilet",
        "욕실",
        "화장실",
    ]):
        return random.choice(bathroom_pool)

    if (
        "floor" in raw
        or "plan" in raw
        or image_category == "floorplan"
    ):
        return random.choice(floorplan_pool)

    if image_category in [
        "building",
        "birdview",
    ]:
        return random.choice(exterior_pool)

    return random.choice(general_pool)

def is_floorplan_image(img):

    category = str(
        img.get("image_category") or ""
    ).lower()

    image_type = str(
        img.get("image_type") or ""
    ).lower()

    file_name = str(
        img.get("file_name") or ""
    ).lower()

    raw = " ".join([
        category,
        image_type,
        file_name,
    ])

    keywords = [
        "floor",
        "plan",
        "평면",
        "도면",
        "타입",
    ]

    if category == "floorplan":
        return True

    return any(k in raw for k in keywords)



def is_location_candidate_image(img):
    """
    입지/주변환경용 이미지 후보.
    같은 단지의 공용 외관/전경 사진이 반복되는 것은 정상 허용하되,
    중개사/평면도/실내 위주 이미지는 제외한다.
    """
    if not img:
        return False

    if is_gallery_excluded_image(img):
        return False

    if is_floorplan_image(img):
        return False

    category = str(img.get("image_category") or "").lower()
    image_type = str(img.get("image_type") or "").lower()
    file_name = str(img.get("file_name") or "").lower()
    raw = " ".join([category, image_type, file_name])

    if category in ["realtor", "floorplan"]:
        return False

    if category in ["building", "birdview", "outside", "view", "complex", "exterior"]:
        return True

    if any(k in raw for k in [
        "building", "birdview", "outside", "exterior", "complex",
        "단지", "외관", "전경", "조경", "입지", "주변", "view"
    ]):
        return True

    return False


def pick_location_images(images, max_count=4):
    """입지 블록에 사용할 외관/단지/전경 이미지 최대 4장 선택."""
    selected = []
    seen = set()

    for img in images or []:
        if not is_location_candidate_image(img):
            continue

        url = image_url(img)
        if not url:
            continue

        key = url.split("?")[0].strip().lower()
        if key in seen:
            continue

        seen.add(key)
        selected.append(img)

        if len(selected) >= max_count:
            break

    return selected


def location_image_block(images):
    """
    입지/주변환경 이미지 블록.
    모든 이미지는 1행 1이미지로 출력한다.
    """
    selected = pick_location_images(images, 4)

    if not selected:
        return ""

    parts = [h2("입지 참고 이미지")]

    for img in selected:
        url = image_url(img)
        if not url:
            continue

        parts.append(f"""
<div style="margin:26px 0; text-align:center; clear:both;">
  <img src="{esc(url)}" alt="입지 및 주변환경 참고 이미지"
    style="width:100%; max-width:900px; height:auto; object-fit:cover; border-radius:18px; display:block; margin:0 auto; box-shadow:0 4px 18px rgba(0,0,0,0.08);">
</div>
""".strip())

    return "\n".join(parts)

def main_gallery_block(images):
    """
    매물 주요 이미지.
    네이버 블로그 붙여넣기 후 이미지가 작게 우측 배치되는 문제를 줄이기 위해
    모든 이미지를 1행 1이미지 세로형으로 출력한다.
    """
    if not images:
        return ""

    selected = []
    seen = set()

    for img in images:
        if is_gallery_excluded_image(img):
            continue

        if is_floorplan_image(img):
            continue

        url = image_url(img)
        if not url:
            continue

        key = url.split("?")[0].strip().lower()
        if key in seen:
            continue

        seen.add(key)
        selected.append(img)

    if not selected:
        return ""

    parts = [h2("매물 주요 이미지")]

    for img in selected[:18]:
        url = image_url(img)
        if not url:
            continue

        parts.append(f"""
<div style="margin:28px 0; text-align:center; clear:both;">
  <img src="{esc(url)}" alt="매물 이미지"
    style="width:100%; max-width:900px; height:auto; display:block; margin:0 auto; border-radius:16px; object-fit:cover; box-shadow:0 4px 18px rgba(0,0,0,0.08);">
</div>
""".strip())

    return "\n".join(parts)


def small_thumbnail_grid(images):
    """
    추가 참고 이미지.
    썸네일 그리드 대신 1행 1이미지로 출력한다.
    """
    safe_images = []
    seen = set()

    for img in images or []:
        if is_gallery_excluded_image(img):
            continue

        if is_floorplan_image(img):
            continue

        url = image_url(img)
        if not url:
            continue

        key = url.split("?")[0].strip().lower()
        if key in seen:
            continue

        seen.add(key)
        safe_images.append(img)

    if not safe_images:
        return ""

    html_parts = []

    for img in safe_images[:12]:
        url = image_url(img)
        if not url:
            continue

        html_parts.append(f"""
<div style="margin:24px 0; text-align:center; clear:both;">
  <img src="{esc(url)}"
    style="width:100%; max-width:900px; height:auto; object-fit:cover; border-radius:14px; border:1px solid #e5e7eb; background:#fff; display:block; margin:0 auto;">
</div>
""".strip())

    return "\n".join(html_parts)


def title_block(title):
    return f"""
<div style="margin:18px 0 26px; padding-bottom:18px; border-bottom:2px solid #222;">
  <h1 style="font-size:30px; line-height:1.4; color:#111; margin:0; font-weight:800;">
    {esc(title)}
  </h1>
</div>
""".strip()


def build_title(detail):
    title = detail.get("article_title") or detail.get("article_name") or "추천 매물 소개"
    title = normalize_title_duplicate_dong(title)

    parts = [
        title,
        detail.get("real_estate_type") or "",
        detail.get("trade_type") or "",
        detail.get("price_text") or "",
    ]

    clean_parts = []

    for item in parts:
        item = normalize_title_duplicate_dong(str(item or "").strip())

        if not item:
            continue

        if item in clean_parts:
            continue

        clean_parts.append(item)

    return " · ".join(clean_parts)

def basic_table(title, rows, label_width="32%", table_class=""):
    trs = ""

    for label, value in rows:
        if value is None or value == "" or value == 0:
            continue

        trs += f"""
<tr>
  <td style="width:{esc(label_width)}; padding:10px; border:1px solid #ddd; background:#f7f7f7; font-weight:bold;">{esc(label)}</td>
  <td style="padding:10px; border:1px solid #ddd;">{esc(value)}</td>
</tr>
"""

    if not trs:
        return ""

    return f"""
{h2(title) if title else ""}
<table{f' class="{esc(table_class)}"' if table_class else ""} style="width:100%; border-collapse:collapse; margin:18px 0; font-size:15px;">
  {trs}
</table>
""".strip()



LAND_REAL_ESTATE_TYPES = {
    "대", "대지", "전", "답", "임야", "잡종지", "공장용지", "창고용지", "토지", "도로", "구거"
}


def is_land_property(detail):
    """
    토지 매물 여부 판별.

    중요:
    - 부분 문자열 검색으로 '전'을 찾으면 안 된다.
      예: 전세, 대전 안의 '전' 때문에 아파트가 토지로 오판된다.
    - raw_json/DB의 명확한 건축물 유형이 있으면 무조건 False.
    """
    detail = detail or {}

    values = [
        detail.get('real_estate_type'),
        detail.get('real_estate_type_name'),
        raw_json_get(detail, ['articleDetail', 'realestateTypeName']),
        raw_json_get(detail, ['articleAddition', 'articleRealEstateTypeName']),
        raw_json_get(detail, ['articleAddition', 'realEstateTypeName']),
        raw_json_get(detail, ['articleDetail', 'buildingTypeName']),
    ]

    codes = [
        raw_json_get(detail, ['articleDetail', 'tradeBuildingTypeCode']),
        raw_json_get(detail, ['articleDetail', 'articleTypeCode']),
        raw_json_get(detail, ['articleDetail', 'realestateTypeCode']),
        raw_json_get(detail, ['articleAddition', 'articleRealEstateTypeCode']),
        raw_json_get(detail, ['articleAddition', 'realEstateTypeCode']),
    ]

    exact_building_types = {
        '아파트', '오피스텔', '빌라', '연립', '다세대', '주택',
        '상가', '상가점포', '사무실', '공장', '창고', '분양권', '입주권',
    }
    building_codes = {
        'APT', 'A01', 'OPST', 'OP', 'VL', 'DDDGG', 'JWJT',
        'SG', 'SMS', 'GJCG', 'GM', 'CW',
    }

    for value in values:
        v = str(value or '').strip()
        if v in exact_building_types:
            return False

    for code in codes:
        c = str(code or '').strip().upper()
        if c in building_codes:
            return False

    exact_land_types = {
        '대', '전', '답', '임야', '잡종지', '공장용지', '창고용지',
        '토지', '대지', '도로', '구거', '토지/임야',
    }
    land_codes = {'TJ', 'LAND', 'TOJI', 'E03'}

    for value in values:
        v = str(value or '').strip()
        if v in exact_land_types:
            return True

    for code in codes:
        c = str(code or '').strip().upper()
        if c in land_codes:
            return True

    # 긴 단어만 보조 검색 허용. 한 글자 지목(대/전/답)은 절대 부분검색 금지.
    text = ' '.join([
        str(detail.get('real_estate_type') or ''),
        str(detail.get('real_estate_type_name') or ''),
        str(raw_json_get(detail, ['articleDetail', 'realestateTypeName']) or ''),
        str(raw_json_get(detail, ['articleAddition', 'articleRealEstateTypeName']) or ''),
    ])

    for word in ['토지', '대지', '임야', '잡종지', '공장용지', '창고용지', '계획관리지역', '자연녹지', '생산녹지']:
        if word in text:
            return True

    return False


def land_type_label(detail):
    value = str(
        (detail or {}).get("real_estate_type")
        or (detail or {}).get("real_estate_type_name")
        or raw_json_get(detail, ["articleDetail", "realestateTypeName"])
        or raw_json_get(detail, ["articleAddition", "articleRealEstateTypeName"])
        or raw_json_get(detail, ["articleDetail", "articleName"])
        or ""
    ).strip()

    if value in ["대", "대지"]:
        return "대지"

    if value:
        return value

    return "토지"


COMMERCIAL_REAL_ESTATE_TYPES = {
    "상가", "상가점포", "단지내상가", "사무실", "공장", "창고",
    "건물", "지식산업센터",
}

FACTORY_WAREHOUSE_TYPES = {
    "공장", "창고", "공장/창고", "공장·창고",
}


def is_factory_warehouse_property(detail):
    if is_land_property(detail):
        return False
    values = [
        detail.get("real_estate_type"),
        detail.get("real_estate_type_name"),
        raw_json_get(detail, ["articleDetail", "realestateTypeName"]),
        raw_json_get(detail, ["articleDetail", "buildingTypeName"]),
        raw_json_get(detail, ["articleAddition", "articleRealEstateTypeName"]),
    ]
    codes = [
        detail.get("_raw_article_type_code"),
        raw_json_get(detail, ["articleDetail", "articleTypeCode"]),
        raw_json_get(detail, ["articleDetail", "realestateTypeCode"]),
        raw_json_get(detail, ["articleAddition", "articleRealEstateTypeCode"]),
        raw_json_get(detail, ["articleAddition", "realEstateTypeCode"]),
    ]
    text = " ".join(str(value or "") for value in values)
    return (
        any(value in text for value in FACTORY_WAREHOUSE_TYPES)
        or any(str(code or "").strip().upper() in {"GJCG", "GM", "CW"} for code in codes)
    )


def is_commercial_property(detail):
    if is_land_property(detail):
        return False
    values = [
        detail.get("real_estate_type"),
        detail.get("real_estate_type_name"),
        raw_json_get(detail, ["articleDetail", "realestateTypeName"]),
        raw_json_get(detail, ["articleDetail", "buildingTypeName"]),
        raw_json_get(detail, ["articleAddition", "articleRealEstateTypeName"]),
    ]
    codes = [
        detail.get("_raw_article_type_code"),
        raw_json_get(detail, ["articleDetail", "articleTypeCode"]),
        raw_json_get(detail, ["articleDetail", "realestateTypeCode"]),
        raw_json_get(detail, ["articleAddition", "articleRealEstateTypeCode"]),
    ]
    text = " ".join(str(value or "") for value in values)
    return (
        any(value in text for value in COMMERCIAL_REAL_ESTATE_TYPES)
        or any(str(code or "").strip().upper() in {"SG", "SMS", "GJCG", "GM", "CW"} for code in codes)
    )


def commercial_intro_block(detail, ai_sections):
    # 서버 초안 간소화: 상가의 "매물 조건 안내" 블록은 출력하지 않는다.
    return ""


def factory_warehouse_intro_block(detail, ai_sections):
    return paragraph_block("공장·창고 매물 안내", (ai_sections or {}).get("intro"))


def _area_square_meter(value):
    try:
        number = float(value)
    except (TypeError, ValueError):
        return ""
    if number <= 0:
        return ""
    return f"{number:,.2f}".rstrip("0").rstrip(".") + "㎡"


def factory_warehouse_summary_table(detail):
    rows = [
        ("거래유형", detail.get("trade_type") or raw_json_get(detail, ["articleDetail", "tradeTypeName"])),
        ("매물유형", detail.get("real_estate_type") or raw_json_get(detail, ["articleDetail", "realestateTypeName"])),
        ("가격", detail.get("price_text")),
        ("대지면적", _area_square_meter(raw_json_get(detail, ["articleSpace", "groundSpace"]))),
        ("연면적", _area_square_meter(raw_json_get(detail, ["articleSpace", "totalSpace"]))),
        ("건축면적", _area_square_meter(raw_json_get(detail, ["articleSpace", "buildingSpace"]))),
        ("층수", format_floor_display(detail.get("floor_info"))),
        ("건축물 용도", detail.get("building_usage") or detail.get("building_use") or raw_json_get(detail, ["articleDetail", "lawUsage"])),
        ("사용승인·준공", detail.get("use_approval_date") or raw_json_get(detail, ["articleFacility", "buildingUseAprvYmd"])),
        ("주차", detail.get("parking_info") or raw_json_get(detail, ["articleDetail", "parkingPossibleYN"])),
        ("입주 가능일", detail.get("move_in_type") or detail.get("move_in_date") or raw_json_get(detail, ["articleDetail", "moveInTypeName"])),
    ]
    return basic_table("공장·창고 핵심 정보", rows)


def factory_warehouse_checklist(detail):
    items = [
        "계약 전 건축물대장상 용도와 실제 사용 가능한 업종을 확인해 주세요.",
        "제조업 운영 시 필요한 전력 용량과 소방·환경·인허가 조건을 확인해 주세요.",
        "대형 차량 진입, 진입도로 폭, 마당 사용 범위와 상하차 동선을 현장에서 확인해 주세요.",
        "사무실·화장실 등 부속 공간의 포함 범위와 실제 사용 상태를 확인해 주세요.",
        "표시된 대지·건물 면적과 입주 가능일, 주차 조건은 상담 시점에 다시 확인해 주세요.",
    ]
    return f"""
{h2("공장·창고 계약 전 확인사항")}
<ul style="padding-left:22px; margin:18px 0; font-size:16px; color:#333;">
  {''.join(f'<li style="margin:8px 0; line-height:1.8;">{esc(item)}</li>' for item in items)}
</ul>
""".strip()


def commercial_summary_table(detail):
    rows = [
        ("거래유형", detail.get("trade_type") or raw_json_get(detail, ["articleDetail", "tradeTypeName"])),
        ("매물유형", detail.get("real_estate_type") or raw_json_get(detail, ["articleDetail", "realestateTypeName"])),
        ("가격", detail.get("price_text")),
        ("면적", detail.get("area_info")),
        ("층수", format_floor_display(detail.get("floor_info"))),
        ("건축물 용도", detail.get("building_usage") or detail.get("building_use") or raw_json_get(detail, ["articleDetail", "lawUsage"])),
        ("관리비", detail.get("management_cost") or raw_json_get(detail, ["articleDetail", "monthlyManagementCost"])),
        ("주차", detail.get("parking_info") or raw_json_get(detail, ["articleDetail", "parkingPossibleYN"])),
        ("입점 가능일", detail.get("move_in_type") or detail.get("move_in_date") or raw_json_get(detail, ["articleDetail", "moveInTypeName"])),
    ]
    return basic_table("상가·업무용 매물 핵심 정보", rows)


def commercial_checklist(detail):
    items = [
        "계약 전 해당 건축물에서 실제 영업 가능한 업종인지 확인해 주세요.",
        "관리비의 포함 항목과 부가세, 권리금 여부를 상담 시점에 다시 확인해 주세요.",
        "주차 가능 대수와 고객·직원 출입 동선은 현장에서 확인하는 것이 좋습니다.",
        "표시된 면적과 층수, 입점 가능일은 최신 매물정보를 기준으로 다시 확인해 주세요.",
    ]
    return f"""
{h2("계약 전 확인사항")}
<ul style="padding-left:22px; margin:18px 0; font-size:16px; color:#333;">
  {''.join(f'<li style="margin:8px 0; line-height:1.8;">{esc(item)}</li>' for item in items)}
</ul>
""".strip()


def land_summary_table(detail):
    raw = parse_detail_raw_json(detail or {})
    ground_space = raw_json_get(detail, ["articleSpace", "groundSpace"])
    area_text = detail.get("area_info") or (f"대지면적 {ground_space}㎡" if ground_space else "")

    usage = (
        detail.get("building_usage")
        or detail.get("building_use")
        or raw_json_get(detail, ["articleDetail", "currentUsage"])
        or raw_json_get(detail, ["articleDetail", "recommendUsage"])
    )

    ground = raw.get("articleGround") or {}
    road_text = ""
    if ground:
        road_text = "있음" if ground.get("roadYN") == "Y" else ("없음" if ground.get("roadYN") == "N" else "")
    build_allowed = ""
    if ground:
        build_allowed = "가능" if ground.get("buildingAllowedYN") == "Y" else ("없음" if ground.get("buildingAllowedYN") == "N" else "")

    rows = [
        ("거래유형", detail.get("trade_type") or raw_json_get(detail, ["articleDetail", "tradeTypeName"])),
        ("매물유형", land_type_label(detail)),
        ("가격", detail.get("price_text") or raw_json_get(detail, ["articleAddition", "dealOrWarrantPrc"])),
        ("면적", area_text),
        ("용도지역", usage),
        ("진입도로", road_text),
        ("건축허가", build_allowed),
    ]

    return basic_table("토지 핵심 정보", rows)


def land_checklist(detail):
    items = []

    if detail.get("price_text"):
        items.append(f"가격 조건은 {detail.get('price_text')}입니다.")
    if detail.get("area_info"):
        items.append(f"토지 면적은 {detail.get('area_info')}입니다.")
    if detail.get("building_usage") or detail.get("building_use"):
        items.append(f"용도는 {detail.get('building_usage') or detail.get('building_use')}입니다.")
    if detail.get("address") or detail.get("road_address"):
        items.append("소재지와 주변 도로 접근성을 함께 확인해보시는 것이 좋습니다.")

    items.extend([
        "토지는 현장 진입로, 접도 조건, 지형 상태를 반드시 함께 확인해야 합니다.",
        "건축 또는 개발 목적이 있다면 토지이용계획과 인허가 가능 여부를 별도로 확인하는 것이 안전합니다.",
    ])

    li = ""
    for item in items[:6]:
        li += f"""
<li style="margin:8px 0; line-height:1.8;">{esc(item)}</li>
"""

    return f"""
{h2("토지 확인 포인트")}
<ul style="padding-left:22px; margin:18px 0; font-size:16px; color:#333;">
  {li}
</ul>
""".strip()


def land_intro_block(detail, ai_sections):
    trade = detail.get("trade_type") or ""
    price = detail.get("price_text") or ""
    land_type = land_type_label(detail)
    area = detail.get("area_info") or ""

    lines = [
        f"이번 매물은 {land_type} 유형으로 확인되는 토지 매물입니다.",
    ]

    if trade or price:
        lines.append(f"{trade} 조건이며 가격은 {price} 기준으로 안내드립니다.".strip())

    if area:
        lines.append(f"면적은 {area} 기준으로, 활용 목적에 따라 현장 조건을 함께 검토해보시면 좋습니다.")

    return paragraph_block("토지 개요", "\n".join([x for x in lines if x]))



def is_land_safe_ai_text(text):
    """
    토지 본문에 주거형 기본 AI 문구가 섞이는 것을 차단한다.
    """
    text = str(text or "").strip()
    if not text:
        return False

    bad_words = [
        "실거주", "주거 안정", "생활 편의", "생활 동선", "구조", "채광", "방향",
        "단지 분위기", "관리 상태", "관리비", "방수", "욕실", "층수", "주차",
        "학교시설", "학군", "평면", "내부", "거실", "주방",
    ]

    if any(w in text for w in bad_words):
        return False

    good_words = [
        "토지", "대지", "임야", "전", "답", "도로", "접도", "진입로", "지목",
        "용도지역", "건폐율", "용적률", "개발", "건축", "인허가", "토지이용계획",
        "현장", "지형", "지세", "경계", "농지", "보전", "계획관리", "산뷰",
        "원수산", "택지", "타운하우스",
    ]

    return any(w in text for w in good_words)


def land_ai_or_fallback(ai_sections, key, fallback):
    text = (ai_sections or {}).get(key) or ""
    return text if is_land_safe_ai_text(text) else fallback


def land_broker_original_block(detail):
    raw = land_raw_detail_description(detail)
    formatted = format_broker_original_text(raw)
    if not formatted:
        return ""

    paragraphs = ""
    for line in formatted.split("\n"):
        paragraphs += f'<p style="margin:0 0 12px 0;">{esc(line)}</p>'

    return f"""
<div style="margin:40px 0; padding:26px; background:#f8fafc; border:1px solid #e5e7eb; border-radius:20px;">
  <div style="font-size:22px; font-weight:800; color:#111827; margin-bottom:16px; line-height:1.5;">
    중개사 매물설명
  </div>
  <div style="font-size:16px; line-height:1.95; color:#374151; word-break:keep-all;">
    {paragraphs}
  </div>
</div>
""".strip()



def land_use_block(detail, ai_sections):
    fallback = (
        "토지 매물은 단순 가격보다 입지, 도로 접근성, 주변 개발 흐름, 실제 활용 가능성을 함께 보는 것이 중요합니다. "
        "건축이나 장기 보유 목적이 있다면 토지이용계획과 인허가 가능 여부를 상담 시점에 다시 확인해보시는 것이 좋습니다."
    )
    return paragraph_block("활용 가능성", land_ai_or_fallback(ai_sections, "point", fallback))


def land_location_block(detail, ai_sections):
    fallback = (
        "토지는 현장 위치와 접근성이 특히 중요합니다. 지도상 거리뿐 아니라 실제 진입 동선, 주변 도로 폭, 인접 토지 이용 현황을 함께 확인해보시면 판단에 도움이 됩니다."
    )
    return paragraph_block("입지 및 접근성", land_ai_or_fallback(ai_sections, "location", fallback))


def land_investment_block(detail, ai_sections):
    fallback = (
        "토지 가격은 용도지역, 면적, 도로 조건, 주변 거래 사례에 따라 체감 가치가 달라질 수 있습니다. "
        "계약 전 공부서류와 현장 상태를 함께 확인하고, 필요 시 세무·건축 관련 사항도 별도 검토하는 것을 권장드립니다."
    )
    return paragraph_block("투자 검토 포인트", land_ai_or_fallback(ai_sections, "price", fallback))



def summary_table(detail):
    if is_land_property(detail):
        return land_summary_table(detail)

    rows = [
        ("거래유형", detail.get("trade_type")),
        ("매물유형", detail.get("real_estate_type")),
        ("가격", detail.get("price_text")),
        ("면적", detail.get("area_info")),
        ("층수", format_floor_display(detail.get("floor_info"))),
        ("방향", display_direction(detail)),
        ("주차", detail.get("parking_info")),
        ("용도", detail.get("building_usage") or detail.get("building_use")),
    ]

    return basic_table("매물 핵심 정보", rows)


def complex_table(detail):
    # 매물 주소 데이터는 초안·법정 명시사항 계산에 그대로 보존한다.
    # 다만 일반 본문의 별도 주소 한 줄 표는 공개하지 않는다.
    return ""


def realtor_table(detail):
    info = detail.get("realtor_info") or {}

    rows = [
        ("중개사무소", info.get("office_name")),
        ("대표자", info.get("representative_name")),
        ("개설등록번호", clean_license_number(info.get("license_number"))),
        ("사업자번호", info.get("business_number")),
        ("대표전화", info.get("office_phone")),
        ("휴대폰", info.get("mobile_phone")),
        ("단체아이디", info.get("naver_realtor_id")),
    ]

    return basic_table("중개사 정보", rows)


def price_table(detail):
    prices = detail.get("market_prices") or []

    if not prices:
        return ""

    rows_html = ""

    for item in prices[:8]:
        label = item.get("price_type") or item.get("trade_type") or "가격정보"
        value = item.get("price_text") or item.get("price_amount") or ""

        if not value:
            continue

        rows_html += f"""
<tr>
  <td style="padding:10px; border:1px solid #ddd; background:#f7f7f7; font-weight:bold;">{esc(label)}</td>
  <td style="padding:10px; border:1px solid #ddd;">{esc(value)}</td>
</tr>
"""

    if not rows_html:
        return ""

    return f"""
{h2("시세 및 가격 참고 정보")}
<table style="width:100%; border-collapse:collapse; margin:18px 0; font-size:15px;">
  {rows_html}
</table>
""".strip()


def school_table(detail):
    schools = detail.get("schools") or []
    if not schools:
        return ""

    grouped = {
        "초등학교": [],
        "중학교": [],
        "고등학교": [],
        "인근 학교": [],
    }
    seen = set()
    for item in schools[:20]:
        name = str(item.get("school_name") or item.get("name") or "").strip()
        if not name or name in seen:
            continue
        seen.add(name)

        school_type = str(item.get("school_type") or item.get("type") or "").strip()
        distance = str(item.get("distance_text") or item.get("distance") or "").strip()
        relation = str(
            item.get("relation_type")
            or item.get("school_relation")
            or item.get("assignment_type")
            or ""
        ).strip()

        if school_type not in grouped:
            if name.endswith(("초", "초등학교")):
                school_type = "초등학교"
            elif name.endswith(("중", "중학교")):
                school_type = "중학교"
            elif name.endswith(("고", "고등학교")):
                school_type = "고등학교"
            else:
                school_type = "인근 학교"

        value_parts = [name]
        if distance:
            value_parts.append(distance)
        # 원문에 배정 관계가 명시된 경우에만 개별 학교명 뒤에 표시한다.
        if "배정" in relation:
            value_parts[0] = f"{name}(배정)"
        grouped[school_type].append(" · ".join(value_parts))

    rows = [
        (label, ", ".join(values))
        for label, values in grouped.items()
        if values
    ]

    if not rows:
        return ""

    table = basic_table("인근학교", rows, label_width="22%", table_class="realestate-nearby-school-table")
    return table + """
<p style="margin:8px 0 30px; color:#64748b; font-size:13px; line-height:1.75;">
학교 배정과 통학구역은 변동될 수 있습니다. ‘배정학교’ 표시는 수집 원문에 배정 관계가 명시된 경우에만 사용하며, 최종 배정은 관할 교육기관에서 확인해 주세요.
</p>
""".strip()



def get_realtor_total_listing_url(detail):
    """
    중개사 전체매물 링크 후보.
    fin.land URL이 있으면 우선 사용하고, 없으면 naver_realtor_id 기반 모바일 중개사 페이지를 사용한다.
    """
    info = detail.get("realtor_info") or {}

    candidates = [
        detail.get("realtor_total_listing_url"),
        detail.get("total_listing_url"),
        detail.get("agency_listing_url"),
        detail.get("fin_land_url"),
        detail.get("naver_agency_url"),
        info.get("realtor_total_listing_url"),
        info.get("total_listing_url"),
        info.get("agency_listing_url"),
        info.get("fin_land_url"),
        info.get("naver_agency_url"),
    ]

    for url in candidates:
        url = str(url or "").strip()
        if url.startswith("http://") or url.startswith("https://"):
            return url

    naver_realtor_id = (
        info.get("naver_realtor_id")
        or detail.get("naver_realtor_id")
        or detail.get("realtor_id_text")
        or ""
    )

    naver_realtor_id = str(naver_realtor_id or "").strip()

    if naver_realtor_id:
        return f"https://m.land.naver.com/agency/info/{esc(naver_realtor_id)}"

    return ""


def get_realtor_banner_image_url(detail):
    """
    중개사 전체매물 배너 이미지 URL 후보.
    Step2에서 generate_blog_drafts.py가 Cafe24 CDN 배너 URL을 주입한다.
    """
    info = detail.get("realtor_info") or {}

    candidates = [
        detail.get("middle_banner_image_url"),
        detail.get("middle_banner_cdn_url"),
        detail.get("banner_image_url"),
        detail.get("realtor_banner_cdn_url"),
        detail.get("realtor_banner_url"),
        detail.get("agency_banner_cdn_url"),
        detail.get("agency_banner_url"),
        info.get("middle_banner_image_url"),
        info.get("middle_banner_cdn_url"),
        info.get("banner_image_url"),
        info.get("realtor_banner_cdn_url"),
        info.get("realtor_banner_url"),
        info.get("agency_banner_cdn_url"),
        info.get("agency_banner_url"),
    ]

    for url in candidates:
        url = str(url or "").strip()
        if url.startswith("http://") or url.startswith("https://"):
            return url

    return ""



def get_middle_banner_background_url(detail):
    """
    중간 CTA 배너 배경 이미지.
    운영 전용 mybox/header_images/officetel 이미지만 사용한다.
    매물 수집 사진이나 storage/officetel은 사용하지 않는다.
    """
    import os
    import random

    folders = [r"D:\honghee\blog_api\mybox\header_images\officetel"]

    for folder in folders:
        try:
            if not os.path.isdir(folder):
                continue

            files = [
                os.path.join(folder, name)
                for name in os.listdir(folder)
                if name.lower().endswith(".png")
            ]

            if not files:
                continue

            picked = random.choice(files).replace("\\", "/")

            # Flask 정적 경로가 /mybox 로 열려 있는 경우를 우선 지원
            normalized = picked.replace("\\", "/")
            mybox_marker = "/blog_api/mybox/"
            if mybox_marker in normalized:
                rel = normalized.split(mybox_marker, 1)[1]
                return "http://61.32.69.107:9100/mybox/" + rel

        except Exception:
            continue

    return ""


def get_realtor_phone_for_banner(detail):
    realtor_info = detail.get("realtor_info") or {}

    candidates = [
        detail.get("office_phone"),
        detail.get("mobile_phone"),
        detail.get("phone"),
    ]

    if isinstance(realtor_info, dict):
        candidates.extend([
            realtor_info.get("office_phone"),
            realtor_info.get("mobile_phone"),
            realtor_info.get("phone"),
        ])

    for phone in candidates:
        phone = str(phone or "").strip()
        if phone:
            return phone

    return ""



def extract_realtor_id_for_banner_link(detail):
    import json

    realtor_info = detail.get("realtor_info") or {}

    candidates = [
        detail.get("naver_realtor_id"),
        detail.get("realtor_id_text"),
        detail.get("naver_realtor_code"),
        detail.get("realtor_account"),
        detail.get("realtorId"),
    ]

    if isinstance(realtor_info, dict):
        candidates.extend([
            realtor_info.get("naver_realtor_id"),
            realtor_info.get("realtor_id_text"),
            realtor_info.get("realtorId"),
        ])

    for json_key in ["source_json", "raw_json"]:
        try:
            raw = detail.get(json_key)
            if isinstance(raw, str):
                raw = json.loads(raw or "{}")
            if not isinstance(raw, dict):
                continue

            source_detail = raw.get("detail") or {}
            if isinstance(source_detail, dict):
                article_detail = source_detail.get("articleDetail") or source_detail.get("article_detail") or {}
                if isinstance(article_detail, dict):
                    candidates.append(article_detail.get("realtorId"))
                candidates.append(source_detail.get("realtorId"))

            article_detail = raw.get("articleDetail") or raw.get("article_detail") or {}
            if isinstance(article_detail, dict):
                candidates.append(article_detail.get("realtorId"))

            candidates.append(raw.get("realtorId"))
        except Exception:
            pass

    for value in candidates:
        value = str(value or "").strip()
        if value:
            return value

    return ""


def build_middle_banner_link_url(detail):
    realtor_info = detail.get("realtor_info") or {}

    candidates = [
        detail.get("realtor_listing_url"),
        detail.get("realtor_total_listing_url"),
        detail.get("total_listing_url"),
        detail.get("agency_listing_url"),
        detail.get("fin_land_url"),
    ]

    if isinstance(realtor_info, dict):
        candidates.extend([
            realtor_info.get("realtor_listing_url"),
            realtor_info.get("realtor_total_listing_url"),
            realtor_info.get("total_listing_url"),
            realtor_info.get("agency_listing_url"),
            realtor_info.get("fin_land_url"),
        ])

    for url in candidates:
        url = str(url or "").strip()
        if url.startswith("http"):
            return url

    realtor_id = extract_realtor_id_for_banner_link(detail)
    if realtor_id:
        return f"https://m.land.naver.com/agency/info/{realtor_id}"

    # 마지막 fallback: 링크가 아예 없어도 새창은 열리게 한다.
    return "https://new.land.naver.com/complexes"


def realtor_listing_banner_block(detail):
    """
    본문 중간 CTA 배너.
    네이버 에디터는 CSS background-image를 제거/무시할 수 있으므로,
    실제 배너 이미지 URL이 있으면 <img>로 출력한다.
    없으면 기존 HTML 배너 fallback.
    """
    realtor_info = detail.get("realtor_info") or {}

    link_url = build_middle_banner_link_url(detail)

    office_name = (
        detail.get("office_name")
        or realtor_info.get("office_name")
        or "중개사무소"
    )

    phone = get_realtor_phone_for_banner(detail)

    banner_image_url = get_realtor_banner_image_url(detail)
    banner_url = get_middle_banner_background_url(detail)

    # 생성 API가 만든 600x200 PNG가 있으면 서버/매물HOME 모두 같은 이미지를 사용한다.
    # CSS background-image는 네이버 붙여넣기 과정에서 제거될 수 있으므로 실제 img로 출력한다.
    if banner_image_url:
        return f"""
<div data-layout-role="listing-banner" style="margin:34px 0; padding:18px 0; text-align:center; clear:both;">
  <a href="{esc(link_url)}" target="_blank" rel="noopener noreferrer" style="display:inline-block; width:600px; max-width:100%; text-decoration:none;">
    <img src="{esc(banner_image_url)}" alt="{esc(office_name)} 전화 안내" class="realestate-listing-banner-image" data-layout-role="listing-banner-image" width="600" height="200" style="display:block; width:600px; max-width:100%; height:auto; aspect-ratio:3 / 1; object-fit:cover; margin:0 auto; border:0;" />
  </a>
</div>
""".strip()

    # fallback: CSS background는 네이버에서 제거될 수 있으므로 배경 없이도 보이게 구성
    phone_line = ""
    if phone:
        phone_line = f"""
        <div style="margin-top:10px; font-size:24px; line-height:1.15; color:#ea580c; font-weight:900; letter-spacing:-0.5px;">
          ☎ {esc(phone)}
        </div>
"""

    inner = f"""
  <div style="
      width:600px;
      max-width:100%;
      height:200px;
      margin:0 auto;
      border-radius:16px;
      border:1px solid #dbeafe;
      box-shadow:0 8px 22px rgba(15,23,42,0.10);
      overflow:hidden;
      position:relative;
      cursor:pointer;
      background-color:#17324b;
      background-image:url('{esc(banner_url)}');
      background-size:cover;
      background-position:center;
  ">
    <div style="height:200px; padding:34px 28px 24px; box-sizing:border-box; text-align:center; background:transparent;">
      <div style="font-size:24px; line-height:1.25; color:#fef3c7; font-weight:900; letter-spacing:-0.8px; text-shadow:0 1px 3px rgba(0,0,0,0.75);">
        {esc(office_name)}
      </div>
{phone_line}
    </div>
  </div>
""".rstrip()

    return f"""
<div data-layout-role="listing-banner" style="margin:34px 0; padding:18px 0; text-align:center; clear:both;">
  <a href="{esc(link_url)}" target="_blank" rel="noopener noreferrer" style="display:inline-block; text-decoration:none;">
{inner}
  </a>
</div>
""".strip()

def realtor_profile_block(detail, images):
    realtor_imgs = filter_realtor_images(images)
    info = detail.get("realtor_info") or {}

    office_name = info.get("office_name", "")
    representative_name = info.get("representative_name", "")
    license_number = clean_license_number(info.get("license_number", ""))
    office_phone = info.get("office_phone", "")
    mobile_phone = info.get("mobile_phone", "")
    address = info.get("address") or info.get("road_address") or detail.get("address") or detail.get("road_address") or ""

    photo_url = ""
    if realtor_imgs:
        photo_url = image_url(realtor_imgs[0])

    photo_cell = "&nbsp;"
    if photo_url:
        photo_cell = f"""
<img src="{esc(photo_url)}" alt="중개사 프로필"
  style="width:120px; height:120px; object-fit:cover; border-radius:12px; border:1px solid #e5e7eb; display:block; margin:0 auto; background:#fff;">
""".strip()

    info_lines = ""
    for label, value in [
        ("중개사무소", office_name),
        ("대표자", representative_name),
        ("등록번호", license_number),
        ("대표전화", office_phone),
        ("휴대폰", mobile_phone),
        ("주소", address),
    ]:
        if value:
            info_lines += f"""
<div style="margin:6px 0; line-height:1.7;">
  <strong>{esc(label)}</strong> : {esc(value)}
</div>
"""

    if not info_lines:
        return ""

    return f"""
{h2("상담 및 중개사 안내")}
<table style="width:100%; border-collapse:collapse; margin:18px 0 36px; font-size:15px;">
  <tr>
    <td style="width:160px; padding:18px; border:1px solid #d1d5db; background:#f9fafb; text-align:center; vertical-align:middle;">
      {photo_cell}
    </td>
    <td style="padding:18px; border:1px solid #d1d5db; background:#ffffff; vertical-align:middle; color:#374151;">
      {info_lines}
    </td>
  </tr>
</table>
""".strip()


def legal_notice_block(detail):
    realtor_info = detail.get("realtor_info") or {}

    office_name = realtor_info.get("office_name", "")
    representative_name = realtor_info.get("representative_name", "")
    license_number = clean_license_number(realtor_info.get("license_number", ""))
    business_number = realtor_info.get("business_number", "")
    office_phone = realtor_info.get("office_phone", "")
    mobile_phone = realtor_info.get("mobile_phone", "")

    article_no = detail.get("article_no", "")
    trade_type = detail.get("trade_type", "")
    price_text = detail.get("price_text", "")
    area_info = detail.get("area_info", "")
    floor_info = format_floor_display(detail.get("floor_info", ""))
    address = detail.get("address") or detail.get("road_address") or ""

    now_text = datetime.now().strftime("%Y년 %m월 %d일 %H시 %M분")

    return f"""
<div style="margin:40px 0 20px; padding:22px; border:1px solid #d1d5db; border-radius:14px; background:#f9fafb; font-size:14px; line-height:1.9; color:#374151;">
<div style="font-size:18px; font-weight:700; margin-bottom:14px; color:#111827;">
중개대상물 표시 · 광고 안내
</div>
<p style="margin:10px 0;">
공인중개사법 제18조의2 및 중개대상물의 표시·광고 명시사항 세부기준에 따라 안내드립니다.
</p>
<p style="margin:10px 0;">
본 포스팅의 매물 정보는 포스팅 당시 확인된 진성매물을 기준으로 작성되었습니다.
다만 포스팅을 확인하시는 시점에는 거래 완료, 가격 변경, 조건 변경 등이 있을 수 있으므로
방문 또는 상담 전 반드시 유선으로 현재 매물 존재 여부와 상세 조건을 다시 확인해 주시기 바랍니다.
</p>
<div style="margin-top:18px; padding-top:14px; border-top:1px dashed #cbd5e1;">
{"<div><strong>중개사무소명</strong> : " + esc(office_name) + "</div>" if office_name else ""}
{"<div><strong>대표자명</strong> : " + esc(representative_name) + "</div>" if representative_name else ""}
{"<div><strong>개설등록번호</strong> : " + esc(license_number) + "</div>" if license_number else ""}
{"<div><strong>사업자번호</strong> : " + esc(business_number) + "</div>" if business_number else ""}
{"<div><strong>대표전화</strong> : " + esc(office_phone) + "</div>" if office_phone else ""}
{"<div><strong>휴대폰번호</strong> : " + esc(mobile_phone) + "</div>" if mobile_phone else ""}
{"<div><strong>매물번호</strong> : " + esc(article_no) + "</div>" if article_no else ""}
{"<div><strong>거래유형</strong> : " + esc(trade_type) + "</div>" if trade_type else ""}
{"<div><strong>가격정보</strong> : " + esc(price_text) + "</div>" if price_text else ""}
{"<div><strong>면적정보</strong> : " + esc(area_info) + "</div>" if area_info else ""}
{"<div><strong>층수정보</strong> : " + esc(floor_info) + "</div>" if floor_info else ""}
{"<div><strong>소재지</strong> : " + esc(address) + "</div>" if address else ""}
<div style="margin-top:12px;"><strong>포스팅 일시</strong> : {now_text}</div>
</div>
</div>
""".strip()



def raw_text_source(detail):
    parts = [
        detail.get("article_feature_desc"),
        detail.get("articleFeatureDescription"),
        detail.get("detailDescription"),
        detail.get("description"),
        detail.get("raw_json"),
    ]

    return "\n".join([
        str(x or "")
        for x in parts
        if str(x or "").strip()
    ])


def find_first_value(text, patterns):
    text = str(text or "")

    for pattern in patterns:
        m = re.search(pattern, text)
        if m:
            return m.group(1).strip()

    return ""


def hero_summary_card(detail):
    """
    매물 한눈에 보기.
    네이버 블로그 붙여넣기 안정성을 위해 카드형 div 대신 테이블형으로 출력한다.
    단, 매물 핵심 정보(summary_table)와 중복될 수 있으므로 레이아웃에서는 보조 블록으로만 사용한다.
    """
    rows = [
        ("거래유형", detail.get("trade_type")),
        ("가격", detail.get("price_text")),
        ("면적", detail.get("area_info")),
        ("층수", detail.get("floor_info")),
        ("방향", display_direction(detail)),
        ("입주", detail.get("move_in_type") or detail.get("move_in_date") or detail.get("move_in_info")),
    ]

    return basic_table("매물 한눈에 보기", rows)



def parse_detail_raw_json(detail):
    raw = detail.get("raw_json") or ""

    if isinstance(raw, dict):
        return raw

    if not str(raw or "").strip():
        return {}

    try:
        return json.loads(raw)
    except Exception:
        return {}


def raw_json_get(detail, *paths):
    raw = parse_detail_raw_json(detail or {})
    for path in paths:
        cur = raw
        ok = True
        for key in path:
            if not isinstance(cur, dict) or key not in cur:
                ok = False
                break
            cur = cur.get(key)
        if ok and cur not in [None, "", 0, "0", "-", []]:
            return cur
    return ""


def land_raw_detail_description(detail):
    raw = parse_detail_raw_json(detail or {})
    candidates = [
        (detail or {}).get("article_feature_desc"),
        raw_json_get(detail, ["articleDetail", "detailDescription"]),
        raw_json_get(detail, ["articleDetail", "articleFeatureDescription"]),
        raw_json_get(detail, ["articleAddition", "articleFeatureDesc"]),
    ]
    for value in candidates:
        text = str(value or "").replace("\r", "\n").strip()
        if text and len(re.sub(r"\s+", "", text)) >= 6:
            return text
    return ""


def format_broker_original_text(text):
    text = str(text or "").replace("\r", "\n")
    text = re.sub(r"\n{2,}", "\n", text).strip()

    lines = []
    for line in text.split("\n"):
        line = line.strip()
        line = line.strip("-·ㆍ ")
        if not line:
            continue
        if line not in lines:
            lines.append(line)

    if not lines:
        return ""

    return "\n".join(lines[:100])



def format_won(value):
    if value is None or value == "":
        return ""

    try:
        number = int(float(str(value).replace(",", "").strip()))
    except Exception:
        return str(value)

    if number <= 0:
        return ""

    man = number // 10000
    rest = number % 10000

    if man > 0 and rest > 0:
        return f"{man:,}만 {rest:,}원"

    if man > 0:
        return f"{man:,}만원"

    return f"{number:,}원"


def format_management_cost(value):
    if value is None or value == "":
        return ""

    text = str(value).strip()

    if "원" in text or "만원" in text:
        return text

    try:
        number = float(text.replace(",", ""))
    except Exception:
        return text

    if number <= 0:
        return ""

    if number < 1000:
        if number.is_integer():
            return f"{int(number)}만원"
        return f"{number:g}만원"

    return format_won(number)


def nested_get(obj, path, default=""):
    cur = obj

    for key in path:
        if not isinstance(cur, dict):
            return default

        if key not in cur:
            return default

        cur = cur.get(key)

    return cur if cur is not None else default


def recursive_find_first(obj, key_names):
    key_names = [str(k).lower() for k in key_names]

    if isinstance(obj, dict):
        for key, value in obj.items():
            if str(key).lower() in key_names:
                if value not in [None, "", 0, "0"]:
                    return value

        for value in obj.values():
            found = recursive_find_first(value, key_names)
            if found not in [None, "", 0, "0"]:
                return found

    elif isinstance(obj, list):
        for item in obj:
            found = recursive_find_first(item, key_names)
            if found not in [None, "", 0, "0"]:
                return found

    return ""


def normalize_title_duplicate_dong(title):
    title = str(title or "").strip()

    if not title:
        return ""

    title = re.sub(r"(\b\d{1,4}동)\s+\1\b", r"\1", title)

    parts = title.split()
    cleaned = []

    for part in parts:
        if cleaned and cleaned[-1] == part and re.search(r"\d+동$", part):
            continue

        cleaned.append(part)

    return " ".join(cleaned)



def is_scalar_value(value):
    return not isinstance(value, (dict, list, tuple, set))


def safe_scalar(value):
    if value is None:
        return ""

    if isinstance(value, (dict, list, tuple, set)):
        return ""

    return value


def get_admin_cost_value(admin_cost, key):
    if not isinstance(admin_cost, dict):
        return ""

    value = admin_cost.get(key)

    if is_scalar_value(value):
        return value

    return ""


def get_latest_cost_from_admin(admin_cost, key):
    if not isinstance(admin_cost, dict):
        return ""

    items = admin_cost.get("costsByDate") or []

    if not isinstance(items, list) or not items:
        return ""

    first = items[0] or {}

    if not isinstance(first, dict):
        return ""

    return first.get(key) or ""

def management_tax_block(detail):
    source = raw_text_source(detail)
    raw = parse_detail_raw_json(detail)

    article_detail = raw.get("articleDetail") or {}
    article_tax = raw.get("articleTax") or {}
    land_price_tax = nested_get(raw, ["landPrice", "landPriceTax"], {}) or {}
    admin_cost = raw.get("administrationCostInfo") or {}

    maintenance = (
        get_admin_cost_value(admin_cost, "averageTotalPrice")
        or get_latest_cost_from_admin(admin_cost, "totalPrice")
        or safe_scalar(detail.get("maintenance_cost"))
        or safe_scalar(detail.get("management_cost"))
        or safe_scalar(article_detail.get("maintenanceCost"))
        or find_first_value(source, [
            r"관리비\s*([0-9만,\s]+원)",
            r"월\s*관리비[^\d]*(\d[\d만,\s]+원)",
        ])
    )

    common_management = (
        get_admin_cost_value(admin_cost, "averageCommonPrice")
        or get_latest_cost_from_admin(admin_cost, "commonPrice")
        or find_first_value(source, [r"공용관리비\s*([0-9만,\s]+원)"])
    )

    heating_cost = (
        get_admin_cost_value(admin_cost, "averageHeatPrice")
        or get_latest_cost_from_admin(admin_cost, "heatPrice")
        or find_first_value(source, [r"난방비\s*([0-9만,\s]+원|-원)"])
    )

    etc_management = (
        get_admin_cost_value(admin_cost, "averageEtcPrice")
        or get_latest_cost_from_admin(admin_cost, "etcPrice")
        or find_first_value(source, [r"기타개별관리비\s*([0-9만,\s]+원)"])
    )

    summer_total = get_admin_cost_value(admin_cost, "summerTotalPrice")
    winter_total = get_admin_cost_value(admin_cost, "winterTotalPrice")

    brokerage_fee = (
        safe_scalar(recursive_find_first(raw, [
            "brokerageFee",
            "brokerageCommission",
            "maxBrokerageFee",
            "maxBrokerageCommission",
        ]))
        or find_first_value(source, [
            r"중개보수\s*최대\s*([0-9만,\s]+원)",
            r"중개보수[^\d]*(\d[\d만,\s]+원)",
        ])
    )

    acquisition_tax = (
        safe_scalar(article_tax.get("acquisitionTax"))
        or safe_scalar(article_tax.get("acquisitionTaxPrice"))
        or safe_scalar(article_tax.get("totalAcquisitionTax"))
        or safe_scalar(recursive_find_first(article_tax, [
            "acquisitionTax",
            "acquisitionTaxPrice",
            "totalAcquisitionTax",
        ]))
        or find_first_value(source, [
            r"취득세\s*약\s*([0-9만,\s]+원)",
            r"취득세\s*([0-9만,\s]+원)",
        ])
    )

    property_tax = (
        safe_scalar(land_price_tax.get("propertyTax"))
        or safe_scalar(recursive_find_first(land_price_tax, ["propertyTax"]))
        or find_first_value(source, [r"재산세\s*([0-9만,\s]+원)"])
    )

    local_edu_tax = (
        safe_scalar(article_tax.get("eduTax"))
        or safe_scalar(land_price_tax.get("localEduTax"))
        or safe_scalar(recursive_find_first(article_tax, ["eduTax", "localEduTax"]))
        or safe_scalar(recursive_find_first(land_price_tax, ["localEduTax"]))
        or find_first_value(source, [r"지방교육세\s*([0-9만,\s]+원)"])
    )

    city_area_tax = (
        safe_scalar(land_price_tax.get("cityAreaTax"))
        or safe_scalar(recursive_find_first(land_price_tax, ["cityAreaTax"]))
    )

    rows = [
        ("월 평균 관리비", format_management_cost(maintenance)),
        ("공용관리비", format_management_cost(common_management)),
        ("난방비", format_management_cost(heating_cost)),
        ("기타개별관리비", format_management_cost(etc_management)),
        ("하절기 평균 관리비", format_management_cost(summer_total)),
        ("동절기 평균 관리비", format_management_cost(winter_total)),
        ("예상 취득세", format_won(acquisition_tax)),
        ("재산세", format_won(property_tax)),
        ("지방교육세", format_won(local_edu_tax)),
        ("도시지역분", format_won(city_area_tax)),
        ("중개보수", format_won(brokerage_fee) if str(brokerage_fee).replace(",", "").isdigit() else brokerage_fee),
    ]

    table = basic_table("관리비 및 세금 참고 정보", rows)

    if not table:
        return ""

    note = """
<p style="font-size:14px; line-height:1.8; color:#6b7280; margin:12px 0 0;">
관리비와 세금, 중개보수는 제공 정보 및 산정 기준에 따라 달라질 수 있으므로 계약 전 최종 확인이 필요합니다.
</p>
""".strip()

    return table + "\n" + note


def facility_block(detail):
    facilities = detail.get("facilities") or []

    if not facilities:
        return ""

    grouped = {}

    for item in facilities[:30]:
        category = str(item.get("facility_category") or item.get("category") or "생활편의").strip()
        name = str(item.get("facility_name") or item.get("name") or "").strip()

        if not name:
            continue

        grouped.setdefault(category, [])

        if name not in grouped[category]:
            grouped[category].append(name)

    for category, names in list(grouped.items()):
        names = [str(x or "").strip() for x in names if str(x or "").strip()]

        if any("BRT" in x.upper() for x in names):
            names = [x for x in names if x != "정류장"]

        has_named_park = any(x not in ["공원", "녹지"] and "공원" in x for x in names)
        if has_named_park:
            names = [x for x in names if x not in ["공원", "녹지"]]

        has_named_hospital = any(x != "병원" and "병원" in x for x in names)
        if has_named_hospital:
            names = [x for x in names if x != "병원"]

        has_named_library = any(x != "도서관" and "도서관" in x for x in names)
        if has_named_library:
            names = [x for x in names if x != "도서관"]

        # 같은 의미의 짧은 일반어를 줄인다.
        generic_words = {"상권", "학원가", "생활권", "편의시설"}
        if len(names) >= 2:
            names = [x for x in names if x not in generic_words]

        deduped = []
        for x in names:
            if x not in deduped:
                deduped.append(x)

        grouped[category] = deduped

    label_map = {
        "교통": "교통",
        "transport": "교통",
        "공원/녹지": "공원·녹지",
        "park": "공원·녹지",
        "nature": "공원·녹지",
        "의료": "의료",
        "medical": "의료",
        "hospital": "의료",
        "교육": "교육·학원가",
        "education": "교육·학원가",
        "school": "교육·학원가",
        "생활편의": "생활편의",
        "facility": "생활편의",
        "convenience": "생활편의",
        "shopping": "생활편의",
        "community": "단지 커뮤니티",
        "public": "공공·문화시설",
        "공공": "공공·문화시설",
        "문화": "공공·문화시설",
        "상권": "상권",
    }

    capsule_cells = []

    for category, names in grouped.items():
        names = [x for x in names if x]
        if not names:
            continue

        label = label_map.get(category, category)
        capsule_cells.append(f"""
<td style="padding:6px 8px; vertical-align:middle; border:0;">
  <div style="box-sizing:border-box; padding:12px 18px; border:1px solid #cfe0da; border-radius:999px; background:#ffffff; text-align:center; white-space:normal;">
    <span style="font-size:14px; color:#527068; font-weight:800;">{esc(label)}</span>
    <span style="margin-left:8px; font-size:15px; line-height:1.6; color:#172b26; font-weight:700;">{esc(' · '.join(names[:5]))}</span>
  </div>
</td>
""")

    if not capsule_cells:
        return ""

    rows = ["<tr>" + cell + "</tr>" for cell in capsule_cells]

    return f"""
{h2("생활 편의시설과 주변 환경")}
<table class="realestate-facility-capsule-list" width="100%" style="width:100%; border-collapse:separate; border-spacing:0; margin:10px 0 28px; padding:8px; border-radius:18px; background:#f3f8f6; border:1px solid #dbe7e3;">
  {''.join(rows)}
</table>
""".strip()


def lifestyle_block(detail):
    # 생활편의시설은 facility_block에서 카드형으로 한 번만 출력한다.
    # 중복 문단 생성을 막기 위해 lifestyle_block은 비활성화한다.
    return ""

def normalize_closing_text(text, detail=None):
    """반복되는 마무리 문구를 줄이고, 매물 조건에 맞는 자연스러운 안내문으로 정리한다."""
    raw = str(text or "").strip()
    title = build_title(detail or {}) if detail else ""

    if detail is not None and is_land_property(detail):
        if not is_land_safe_ai_text(raw):
            raw = ""
        land_options = [
            "토지 매물은 공부서류와 현장 상태를 함께 확인하는 것이 중요합니다. 관심 있으시면 진입로, 지목, 용도지역, 인허가 가능 여부를 상담 시점에 다시 확인해보시길 권합니다.",
            "계약 전에는 토지이용계획, 접도 조건, 경계와 지형 상태를 함께 확인하시는 것이 좋습니다. 현장 방문과 중개사 상담을 통해 현재 조건을 한 번 더 점검해 주세요.",
            "건축이나 보유 목적에 따라 검토 기준이 달라질 수 있으므로, 현장 확인과 함께 용도지역·도로 조건·주변 개발 흐름을 종합적으로 살펴보시길 권합니다.",
        ]
        if raw:
            return raw
        idx = abs(hash(title or "land")) % len(land_options)
        return land_options[idx]

    # 너무 자주 반복되는 기본 문구는 대체한다.
    repeated_patterns = [
        "관심 있으신 분들은 직접 현장 확인과 함께 자세한 상담을 받아보시길 추천드립니다",
        "자세한 상담을 받아보시길 추천드립니다",
        "확인해보시면 좋습니다",
    ]

    should_replace = (not raw) or any(x in raw for x in repeated_patterns)

    if not should_replace:
        return raw

    options = [
        "사진과 기본 조건만으로는 확인하기 어려운 부분도 있으니, 실제 방문 시 채광과 동선, 주변 분위기를 함께 살펴보시면 좋겠습니다.",
        "가격과 면적 조건이 맞는다면 현장 방문을 통해 실내 컨디션과 생활 동선을 직접 비교해보시는 것을 권합니다.",
        "같은 단지 안에서도 동·층·향에 따라 체감이 달라질 수 있으니, 상담 전 현재 조건을 한 번 더 확인해보시면 좋겠습니다.",
        "실거주를 고려하신다면 구조와 관리 상태, 주변 이동 동선을 함께 체크해보시면 판단에 도움이 됩니다.",
        "계약 전에는 매물 가능 여부와 세부 조건이 달라질 수 있으므로 중개사무소를 통해 최신 상태를 확인해 주세요.",
    ]

    seed_text = title or raw or "default"
    idx = abs(hash(seed_text)) % len(options)
    return options[idx]


def closing_block(text, detail=None):
    text = normalize_closing_text(text, detail)

    return f"""
<div style="margin:38px 0 20px; padding:20px; border:1px solid #ddd; border-radius:14px; background:#fbfbfb;">
  <p style="font-size:16px; line-height:1.9; margin:0; color:#333;">
    {esc(text)}
  </p>
</div>
""".strip()


def paragraph_block(title, text):
    if not text:
        return ""

    if not str(title or "").strip():
        return p(text)

    return h2(title) + "\n" + p(text)


def feature_comment_block(feature_comment, detail=None):
    if not feature_comment:
        return ""

    title = feature_comment.get("title") or "중개사 매물설명"
    content = feature_comment.get("content") or ""

    if not str(content or "").strip():
        return ""

    if detail is not None and is_land_property(detail):
        if not is_land_safe_ai_text(content):
            return ""

    return f"""
<div style="margin:40px 0; padding:26px; background:#f8fafc; border:1px solid #e5e7eb; border-radius:20px;">
  <div style="font-size:22px; font-weight:800; color:#111827; margin-bottom:16px; line-height:1.5;">
    {esc(title)}
  </div>
  <div style="font-size:16px; line-height:1.95; color:#374151; word-break:keep-all;">
    {"".join([f'<p style="margin:0 0 14px 0;">{nl2br(part)}</p>' for part in str(content or "").split("\n\n") if str(part or "").strip()])}
  </div>
</div>
""".strip()


def choose_layout_type(detail, images, layout_type=None):
    if layout_type:
        return layout_type

    content_images = filter_content_images(images)

    large_count = 0
    small_count = 0

    for img in content_images:
        size_type = classify_image_size(img)

        if size_type == "large":
            large_count += 1

        if size_type == "small":
            small_count += 1

    has_schools = False
    has_prices = bool(detail.get("market_prices"))

    if small_count >= 3:
        return random.choice([
            "small_image_card",
            "checklist_style",
            "photo_grid",
        ])

    if large_count >= 2:
        return random.choice([
            "wide_visual",
            "magazine_style",
            "quiet_luxury",
        ])

    if has_schools:
        return random.choice([
                                ])

    if has_prices:
        return random.choice([
            "price_focus",
            "compare_market",
            "investment_focus",
        ])

    return pick_layout_type()


def content_property_class(detail):
    raw = detail.get("raw_json")
    if not isinstance(raw, dict):
        try:
            raw = json.loads(raw or "{}")
        except Exception:
            raw = {}
    article_detail = raw.get("articleDetail") if isinstance(raw, dict) else {}
    article_addition = raw.get("articleAddition") if isinstance(raw, dict) else {}
    code = str(
        detail.get("_raw_article_type_code")
        or article_detail.get("articleTypeCode")
        or article_addition.get("articleRealEstateTypeCode")
        or ""
    ).strip().upper()
    name = str(
        detail.get("real_estate_type")
        or detail.get("real_estate_type_name")
        or article_detail.get("realestateTypeName")
        or article_addition.get("articleRealEstateTypeName")
        or ""
    ).strip()
    if code in {"SG", "SMS"} or any(x in name for x in ("상가", "점포", "사무실", "업무시설")):
        return "commercial"
    if code in {"GJCG", "GM", "CW"} or any(x in name for x in ("공장", "창고", "지식산업센터")):
        return "factory_warehouse"
    if code in {"TJ", "LAND", "TOJI", "E03"} or name in {
        "토지", "대", "전", "답", "임야", "대지", "잡종지", "공장용지", "창고용지",
    }:
        return "land"
    return "residential"


def checklist(detail):
    if is_land_property(detail):
        return land_checklist(detail)

    items = []

    if detail.get("price_text"):
        items.append(f"가격 조건은 {detail.get('price_text')}입니다.")
    if detail.get("area_info"):
        items.append(f"면적은 {detail.get('area_info')}입니다.")
    floor_display = format_floor_display(detail.get("floor_info"))
    if floor_display:
        items.append(f"층수는 {floor_display}입니다.")
    if detail.get("parking_info"):
        items.append(f"주차는 {detail.get('parking_info')}로 확인됩니다.")
    direction_text = display_direction(detail)
    if direction_text:
        items.append(f"방향은 {direction_text}입니다.")

    if not items:
        return ""

    li = ""

    for item in items:
        li += f"""
<li style="margin:8px 0; line-height:1.8;">{esc(item)}</li>
"""

    return f"""
{h2("확인 포인트")}
<ul style="padding-left:22px; margin:18px 0; font-size:16px; color:#333;">
  {li}
</ul>
""".strip()


def floorplan_block(images):

    floorplans = []

    for img in images:

        if is_floorplan_image(img):
            floorplans.append(img)

    if not floorplans:
        return ""

    html = """
<div style="
    margin:40px 0;
">
"""

    html += """
<h2 style="
    font-size:24px;
    line-height:1.5;
    margin-bottom:18px;
    color:#111827;
    font-weight:800;
">
평면도 및 구조 확인
</h2>
"""

    html += """
<p style="
    font-size:15px;
    line-height:1.9;
    color:#4b5563;
    margin-bottom:24px;
">
실사용 공간 구조와 방 배치를 함께 참고해보실 수 있는 평면도입니다.
</p>
"""

    seen_floorplan_urls = set()

    for img in floorplans[:4]:

        url = image_url(img)

        if not url:
            continue

        key = url.split("?")[0].strip().lower()

        if key in seen_floorplan_urls:
            continue

        seen_floorplan_urls.add(key)

        html += f"""
<div style="
    margin:28px 0;
    padding:20px;
    background:#fafafa;
    border:1px solid #e5e7eb;
    border-radius:18px;
">
  <img
    src="{esc(url)}"
    style="
      width:100%;
      max-width:820px;
      margin:0 auto;
      display:block;
      object-fit:contain;
      border-radius:12px;
      background:#fff;
    ">
</div>
"""

    html += "</div>"

    return html


def build_blog_html(detail, images, ai_sections, layout_type=None, feature_comment=None, extra_images=None):
    detail = normalize_detail_display_types(detail)
    layout_type = choose_layout_type(detail, images, layout_type)

    title = build_title(detail)

    content_images = filter_content_images(images)
    extra_images = extra_images or []
    extra_content_images = filter_content_images(extra_images)

    seen_urls = set()

    primary_images = []

    inside_images = []
    building_images = []
    secondary_images = []

    for img in images:

        if is_gallery_excluded_image(img):
            continue

        if is_floorplan_image(img):
            continue

        category = str(
            img.get("image_category") or ""
        ).lower()

        image_type = str(
            img.get("image_type") or ""
        ).lower()

        raw = " ".join([
            category,
            image_type,
            str(img.get("file_name") or "").lower()
        ])

        if (
            category == "article"
            or any(x in raw for x in [
                "거실",
                "주방",
                "방",
                "침실",
                "욕실",
                "실내",
                "living",
                "kitchen",
                "room",
                "bath",
            ])
        ):
            inside_images.append(img)

        elif category == "building":
            building_images.append(img)

        else:
            secondary_images.append(img)

    for img in content_images:
        url = image_url(img)

        if not url:
            continue

        key = url.split("?")[0].strip().lower()

        if key in seen_urls:
            continue

        seen_urls.add(key)
        primary_images.append(img)

    for img in extra_content_images:
        url = image_url(img)

        if not url:
            continue

        key = url.split("?")[0].strip().lower()

        if key in seen_urls:
            continue

        seen_urls.add(key)
        secondary_images.append(img)

    content_images = primary_images + secondary_images

    hero_img = pick_hero_image(primary_images or content_images)
    hero_url = image_url(hero_img) if hero_img else ""

    gallery_images = []

    for img in content_images:
        if hero_img and image_url(img) == image_url(hero_img):
            continue

        gallery_images.append(img)

    location_images = pick_location_images(gallery_images, 4)
    location_keys = set([
        image_url(img).split("?")[0].strip().lower()
        for img in location_images
        if image_url(img)
    ])

    # 입지 블록에 사용한 이미지는 일반 갤러리에서 중복 노출하지 않는다.
    gallery_images_for_gallery = []
    for img in gallery_images:
        url = image_url(img)
        key = url.split("?")[0].strip().lower() if url else ""
        if key and key in location_keys:
            continue
        gallery_images_for_gallery.append(img)

    blocks = {
        "title": title_block(title),
        "hero": img_tag(hero_url, title, img=hero_img),
        # 매물 첫인상은 제목과 설명 블록을 모두 출력하지 않는다.
        "intro": "",
        "feature_comment": feature_comment_block(feature_comment, detail),
        # 서버 초안 간소화: 입지와 주변 환경 / 공간과 구조는 출력하지 않는다.
        "location": "",
        "space": "",
        "point": paragraph_block("추천 포인트", ai_sections.get("point")),
        "price_text": paragraph_block("가격 조건에 대한 생각", ai_sections.get("price")),
        "school_text": paragraph_block("생활권과 교육환경", ai_sections.get("school")),
        "realtor_text": paragraph_block("중개사 안내", ai_sections.get("realtor")),
        "realtor_banner": realtor_listing_banner_block(detail),
        "closing": closing_block(ai_sections.get("closing"), detail),
        "hero_summary": hero_summary_card(detail),
        "summary": summary_table(detail),
        "management_tax": management_tax_block(detail),
        "lifestyle": lifestyle_block(detail),
        "facilities": facility_block(detail),
        "location_images": location_image_block(location_images),
        "complex": complex_table(detail),
        "prices": price_table(detail),
        "schools": school_table(detail),
        "checklist": checklist(detail),
        "floorplan": floorplan_block(gallery_images_for_gallery),
        "gallery": main_gallery_block(gallery_images_for_gallery),
        "realtor_profile": realtor_profile_block(detail, images),
        "legal_notice": legal_notice_block(detail),
    }

    property_class = content_property_class(detail)

    if is_land_property(detail):
        blocks["intro"] = land_intro_block(detail, ai_sections)
        blocks["feature_comment"] = land_broker_original_block(detail) or ""
        blocks["space"] = ""
        blocks["floorplan"] = ""
        blocks["management_tax"] = ""
        blocks["school_text"] = ""
        blocks["realtor_text"] = ""
        blocks["facilities"] = ""
        blocks["location_images"] = ""
        blocks["location"] = land_location_block(detail, ai_sections)
        blocks["point"] = land_use_block(detail, ai_sections)
        blocks["price_text"] = land_investment_block(detail, ai_sections)
        blocks["summary"] = land_summary_table(detail)
        blocks["checklist"] = land_checklist(detail)

    elif is_commercial_property(detail):
        if is_factory_warehouse_property(detail):
            blocks["intro"] = factory_warehouse_intro_block(detail, ai_sections)
            blocks["summary"] = factory_warehouse_summary_table(detail)
            blocks["checklist"] = factory_warehouse_checklist(detail)
        else:
            blocks["intro"] = commercial_intro_block(detail, ai_sections)
            blocks["summary"] = commercial_summary_table(detail)
            blocks["checklist"] = commercial_checklist(detail)
        blocks["feature_comment"] = land_broker_original_block(detail) or ""
        blocks["space"] = ""
        blocks["floorplan"] = ""
        blocks["school_text"] = ""
        blocks["schools"] = ""
        blocks["realtor_text"] = ""
        blocks["facilities"] = ""
        blocks["location_images"] = ""
        blocks["location"] = paragraph_block("소재지 안내", ai_sections.get("location"))
        blocks["point"] = ""
        blocks["price_text"] = paragraph_block("거래 조건", ai_sections.get("price"))

    # ---------------------------------------------------------
    # 레이아웃 랜덤 V3
    # ---------------------------------------------------------
    # 모든 주요 컨텐츠는 최대한 전부 삽입한다.
    # 랜덤 레이아웃은 '출력 여부'가 아니라 '중간 컨텐츠 순서'를 바꾸는 방식이다.
    core_orders = {
        "story_first": ["intro", "feature_comment", "summary", "checklist", "space", "floorplan", "gallery", "realtor_banner", "point", "location", "facilities", "location_images", "management_tax", "prices"],
        "photo_first": ["summary", "floorplan", "gallery", "realtor_banner", "intro", "space", "point", "location", "facilities", "location_images", "checklist", "management_tax", "prices", "feature_comment"],
        "summary_first": ["summary", "checklist", "management_tax", "prices", "intro", "space", "floorplan", "gallery", "realtor_banner", "location", "facilities", "location_images", "point", "feature_comment"],
        "gallery_story": ["intro", "floorplan", "gallery", "realtor_banner", "space", "point", "summary", "checklist", "location", "facilities", "location_images", "management_tax", "prices", "feature_comment"],
        "price_focus": ["summary", "prices", "management_tax", "checklist", "intro", "point", "floorplan", "gallery", "realtor_banner", "location", "facilities", "location_images", "space", "feature_comment"],
        "location_focus": ["intro", "location", "facilities", "location_images", "summary", "checklist", "floorplan", "gallery", "realtor_banner", "space", "point", "management_tax", "prices", "feature_comment"],
        "recommendation_focus": ["intro", "point", "checklist", "summary", "floorplan", "gallery", "realtor_banner", "space", "location", "facilities", "location_images", "management_tax", "prices", "feature_comment"],
        "magazine_style": ["intro", "space", "floorplan", "gallery", "realtor_banner", "location", "facilities", "location_images", "point", "summary", "checklist", "management_tax", "prices", "feature_comment"],
        "checklist_style": ["summary", "checklist", "intro", "point", "floorplan", "gallery", "realtor_banner", "space", "location", "facilities", "location_images", "management_tax", "prices", "feature_comment"],
        "premium_report": ["summary", "prices", "management_tax", "location", "facilities", "location_images", "floorplan", "gallery", "realtor_banner", "space", "point", "checklist", "intro", "feature_comment"],
        "interview_style": ["feature_comment", "intro", "summary", "point", "space", "floorplan", "gallery", "realtor_banner", "location", "facilities", "location_images", "checklist", "management_tax", "prices"],
        "quiet_luxury": ["intro", "summary", "space", "floorplan", "gallery", "realtor_banner", "point", "location", "facilities", "location_images", "management_tax", "prices", "checklist", "feature_comment"],
        "short_review_style": ["intro", "point", "space", "summary", "floorplan", "gallery", "realtor_banner", "location", "facilities", "location_images", "checklist", "management_tax", "prices", "feature_comment"],
        "wide_visual": ["summary", "gallery", "realtor_banner", "floorplan", "intro", "space", "point", "location", "location_images", "facilities", "checklist", "management_tax", "prices", "feature_comment"],
        "photo_grid": ["summary", "gallery", "realtor_banner", "floorplan", "location_images", "intro", "space", "point", "location", "facilities", "checklist", "management_tax", "prices", "feature_comment"],
        "minimal_modern": ["summary", "intro", "checklist", "space", "floorplan", "gallery", "realtor_banner", "point", "location", "facilities", "location_images", "management_tax", "prices", "feature_comment"],
        "storytelling": ["intro", "space", "point", "feature_comment", "summary", "floorplan", "gallery", "realtor_banner", "location", "facilities", "location_images", "checklist", "management_tax", "prices"],
        "local_life": ["intro", "location", "facilities", "location_images", "point", "summary", "space", "floorplan", "gallery", "realtor_banner", "checklist", "management_tax", "prices", "feature_comment"],
        "broker_expert": ["feature_comment", "summary", "checklist", "management_tax", "prices", "intro", "space", "floorplan", "gallery", "realtor_banner", "location", "facilities", "location_images", "point"],
        "investment_focus": ["summary", "prices", "management_tax", "checklist", "location", "facilities", "intro", "point", "floorplan", "gallery", "realtor_banner", "space", "location_images", "feature_comment"],
        "compare_market": ["summary", "prices", "management_tax", "location", "facilities", "checklist", "intro", "floorplan", "gallery", "realtor_banner", "space", "point", "location_images", "feature_comment"],
        "transport_focus": ["intro", "location", "facilities", "summary", "checklist", "floorplan", "gallery", "realtor_banner", "space", "point", "location_images", "management_tax", "prices", "feature_comment"],
        "family_recommend": ["intro", "facilities", "location", "location_images", "summary", "checklist", "space", "floorplan", "gallery", "realtor_banner", "point", "management_tax", "prices", "feature_comment"],
        "single_life": ["summary", "checklist", "intro", "space", "point", "floorplan", "gallery", "realtor_banner", "location", "facilities", "location_images", "management_tax", "prices", "feature_comment"],
        "real_estate_news": ["summary", "prices", "management_tax", "location", "facilities", "intro", "point", "floorplan", "gallery", "realtor_banner", "space", "checklist", "location_images", "feature_comment"],
        "villa_focus": ["summary", "checklist", "intro", "space", "floorplan", "gallery", "realtor_banner", "location", "facilities", "location_images", "point", "management_tax", "prices", "feature_comment"],
        "office_focus": ["summary", "location", "facilities", "checklist", "intro", "space", "floorplan", "gallery", "realtor_banner", "point", "management_tax", "prices", "location_images", "feature_comment"],
    }

    if is_land_property(detail):
        layout_type = "land_focus"
        core_orders["land_focus"] = [
            "intro",
            "feature_comment",
            "summary",
            "checklist",
            "location",
            "facilities",
            "location_images",
            "point",
            "price_text",
            "gallery",
            "realtor_banner",
        ]

    fallback_order = core_orders.get("story_first")

    order = core_orders.get(layout_type, fallback_order)

    # 혹시 layout_type이 위 목록에 없어도 모든 핵심 블록이 한 번씩 들어가도록 보정한다.
    required_blocks = [
        "intro",
        "feature_comment",
        "summary",
        "complex",
        "schools",
        "checklist",
        "space",
        "floorplan",
        "gallery",
        "realtor_banner",
        "point",
        "location",
        "facilities",
        "location_images",
        "management_tax",
        "prices",
    ]

    patched_order = []
    seen_order = set()

    for key in order:
        if key in seen_order:
            continue
        patched_order.append(key)
        seen_order.add(key)

    for key in required_blocks:
        if key not in seen_order:
            patched_order.append(key)
            seen_order.add(key)

    order = [key for key in patched_order if key not in ["school_text", "hero", "hero_summary", "title"]]

    # 하단 고정 영역
    bottom_order = [
        "realtor_profile",
        "legal_notice",
        "closing",
    ]

    body = []

    for key in order:
        value = blocks.get(key, "")

        if value:
            body.append(value)

    for key in bottom_order:
        value = blocks.get(key, "")

        if value:
            body.append(value)

    draft_html = f"""
<div style="max-width:900px; margin:0 auto; font-family:Arial,'Malgun Gothic',sans-serif; color:#222;">
  {"".join(body)}
</div>
""".strip()

    plain_text = "\n\n".join([
        title,
        ai_sections.get("intro", ""),
        ai_sections.get("location", ""),
        ai_sections.get("space", ""),
        ai_sections.get("point", ""),
        ai_sections.get("price", ""),
        ai_sections.get("school", ""),
        ai_sections.get("realtor", ""),
        normalize_closing_text(ai_sections.get("closing", ""), detail),
    ])

    return {
        "layout_type": layout_type,
        "layout_type_name": get_layout_display_name(layout_type),
        "draft_title": title,
        "draft_html": draft_html,
        "clipboard_html": draft_html,
        "plain_text": plain_text,
    }
