"""하이브리드 HTML 생성 — 원본 이미지(레이아웃 레퍼런스) + 확정 데이터 view → LLM 이 완성 HTML 생성.

데이터는 resolve 로 이미 확정된 값만 컴포넌트에 담겨 들어오고, LLM 은 레이아웃 재현과
마크업 조립만 담당한다. 생성 HTML 의 수치가 확정 데이터 집합에 없으면 환각 경고를 낸다.
"""
from __future__ import annotations

import base64
import json
import re
import subprocess
import tempfile
from pathlib import Path
from typing import Any

from app import prompts as _p
from app.config import BACKEND_ROOT
from app.services import llm as llm_service

NODE_DIR = BACKEND_ROOT / "export_node"

HTML_SYSTEM = _p.get("html", "system")
HTML_USER_TMPL = _p.get("html", "user_template")


def _collect_numbers(obj: Any, out: set[str]) -> None:
    if isinstance(obj, dict):
        for v in obj.values():
            _collect_numbers(v, out)
    elif isinstance(obj, list):
        for v in obj:
            _collect_numbers(v, out)
    elif isinstance(obj, (int, float)):
        out.add(f"{float(obj):.2f}")
    elif isinstance(obj, str):
        for m in re.findall(r"-?\d+\.?\d*", obj):
            try:
                out.add(f"{float(m):.2f}")
            except ValueError:
                pass


def _strip_html(text: str) -> str:
    t = text.strip()
    t = re.sub(r"^```(?:html)?", "", t).strip()
    t = re.sub(r"```$", "", t).strip()
    lo = t.lower()
    start = lo.find("<!doctype")
    if start == -1:
        start = lo.find("<html")
    end = lo.rfind("</html>")
    if start != -1 and end != -1:
        return t[start : end + len("</html>")]
    return t


def _visible_text(html: str) -> str:
    """가시 텍스트만 추출 — style/script/svg 블록과 태그(속성 포함) 제거."""
    t = re.sub(r"<style[\s\S]*?</style>", " ", html, flags=re.I)
    t = re.sub(r"<script[\s\S]*?</script>", " ", t, flags=re.I)
    t = re.sub(r"<svg[\s\S]*?</svg>", " ", t, flags=re.I)  # 차트 좌표/속성 수치 제외
    t = re.sub(r"<[^>]+>", " ", t)  # 남은 태그(속성값 포함) 제거
    return t


def _hallucination_warnings(html: str, view: dict[str, Any]) -> list[str]:
    allowed: set[str] = set()
    _collect_numbers(view.get("components", []), allowed)
    visible = _visible_text(html)
    found: set[str] = set()
    for m in re.findall(r"-?\d+\.?\d*", visible):
        try:
            found.add(f"{float(m):.2f}")
        except ValueError:
            pass
    suspicious = {n for n in found - allowed if not n.endswith(".00")}
    if suspicious:
        return [f"생성 HTML 의 본문 텍스트에 확정 데이터 외 수치 포함(검토 필요): {sorted(suspicious)[:20]}"]
    return []


def render_html_llm(
    view: dict[str, Any],
    image_bytes: bytes,
    mime: str = "image/png",
    background: tuple[bytes, str] | None = None,
    content_top: float = 0.17,
    content_bottom: float = 0.83,
) -> tuple[str, list[str]]:
    """원본(레퍼런스) 이미지 + 확정 view (+선택 배경 이미지)로 완성 HTML 생성. (html, warnings).

    배경은 LLM 이 그리지 않는다. 프롬프트는 .page background 에 __BG__ 토큰을 쓰게 하고,
    생성 후 코드가 실제 배경 data URL 로 치환한다(LLM 의 placeholder 배경 방지).
    content_top/bottom 은 배경에서 컴포넌트를 놓을 수 있는 세로 안전영역(0..1) 이다.
    """
    bg_data_url = ""
    if background is not None:
        w, h = _image_size(background[0])
        bg_data_url = "data:{};base64,{}".format(
            background[1], base64.b64encode(background[0]).decode("ascii")
        )
        top_px = int(h * content_top)
        bottom_px = int(h * (1 - content_bottom))
        bg_note = (
            "The second attached image is the BACKGROUND = the page canvas. "
            f"[BACKGROUND] pixel size: {w}x{h} (width={w}px, height={h}px). "
            "Follow rule 3 EXACTLY: root <div class=\"page\"> with .page width:{w}px;height:{h}px and "
            "background-image:url('__BG__') (literal token __BG__, background-size:100% 100%). "
            "Do NOT draw or invent any background; only use __BG__. body has margin:0 and no padding.\n"
            f"CONTENT SAFE ZONE: header occupies top ~{top_px}px, footer occupies bottom ~{bottom_px}px. "
            f"The .content wrapper MUST be EXACTLY: .content {{ position:absolute; left:0; right:0; "
            f"top:{top_px}px; bottom:{bottom_px}px; padding:12px 24px; box-sizing:border-box; }}. "
            "ALL flowing components go inside .content (system auto-scales it to avoid footer overlap).\n"
            "DO NOT DUPLICATE BACKGROUND ELEMENTS: the background already shows the page title/logo, a "
            "risk-grade legend, an empty bordered '상품 특징' input box, and footer disclaimers. NEVER "
            "re-draw the title, the legend, the '상품 특징' label/box border, or the footer. If a "
            "component's content belongs in an empty box that already exists in the background (e.g. the "
            "product-feature text), place ONLY its text at that location WITHOUT drawing a new box/border."
        ).replace("{w}", str(w)).replace("{h}", str(h))
    else:
        bg_note = "No background image is attached. Use an A4 portrait page."
    user = HTML_USER_TMPL.format(
        brand=view.get("brand", ""),
        title=view.get("title", ""),
        components=json.dumps(view.get("components", []), ensure_ascii=False, indent=2),
        background_note=bg_note,
    )
    images: list[tuple[bytes, str]] = [(image_bytes, mime)]
    if background is not None:
        images.append(background)
    raw = llm_service.call_vision_text(HTML_SYSTEM, user, images)
    html = _strip_html(raw)
    if bg_data_url:
        html = _inject_background(html, bg_data_url)
        html = _align_header_slots(html, background[0])
        html = _inject_fit_script(html)
    warnings = _hallucination_warnings(html, view)
    return html, warnings


def _detect_label_box(image_bytes: bytes) -> tuple[int, int, int, int] | None:
    """배경 헤더의 '네이비 라벨 + 빈 박스'(상품특징형) 영역을 탐지해 텍스트 안전 사각형 반환.

    네이비 라벨 행 범위를 찾고, 라벨 우측 끝~페이지 우측을 박스 내부로 본다. (left, top, right_pad, h).
    탐지 실패 시 None.
    """
    try:
        import io

        from PIL import Image
    except Exception:  # noqa: BLE001
        return None
    try:
        im = Image.open(io.BytesIO(image_bytes)).convert("RGB")
    except Exception:  # noqa: BLE001
        return None
    w, h = im.size
    px = im.load()

    def is_navy(c: tuple[int, int, int]) -> bool:
        r, g, b = c
        return b > 90 and r < 80 and g < 90

    band = [y for y in range(int(h * 0.07), int(h * 0.20))
            if sum(1 for x in range(int(w * 0.03), int(w * 0.18)) if is_navy(px[x, y])) > int(w * 0.04)]
    if not band:
        return None
    top, bot = min(band), max(band)
    mid = (top + bot) // 2
    label_start = int(w * 0.01)
    label_right = label_start
    gap = 0
    for x in range(label_start, int(w * 0.6)):
        if is_navy(px[x, mid]):
            label_right = x
            gap = 0
        else:
            gap += 1
            if gap > int(w * 0.02):
                break
    text_left = label_right + int(w * 0.025)
    return (text_left, top, int(w * 0.025), bot - top)


def _align_header_slots(html: str, bg_bytes: bytes) -> str:
    """LLM 이 만든 product-feature 슬롯(클래스명 변형 포함)을 배경 라벨 박스 위치/높이에 맞춘다."""
    if "product-feature" not in html:
        return html
    box = _detect_label_box(bg_bytes)
    if not box:
        return html
    left, top, right_pad, bh = box
    override = (
        f'[class*="product-feature"]{{position:absolute !important;left:{left}px !important;'
        f'top:{top}px !important;right:{right_pad}px !important;height:{bh}px !important;'
        "display:flex !important;align-items:center !important;margin:0 !important;}"
    )
    return html.replace("</style>", override + "</style>", 1)


_FIT_SCRIPT = """
<script>
(function(){
  function fit(){
    var c = document.querySelector('.content');
    var page = document.querySelector('.page');
    if(!c || !page) return;
    c.querySelectorAll('*').forEach(function(e){
      var cs = getComputedStyle(e);
      if((cs.overflow==='hidden'||cs.overflowY==='hidden') && e.scrollHeight > e.clientHeight+1){
        e.style.overflow='visible'; e.style.height='auto'; e.style.maxHeight='none';
      }
    });
    c.style.transform=''; c.style.transformOrigin='top left';
    var avail = c.clientHeight;
    var inner = c.scrollHeight;
    if(avail>0 && inner>avail+1){
      var s = avail/inner;
      var wrap = document.createElement('div');
      while(c.firstChild){ wrap.appendChild(c.firstChild); }
      c.appendChild(wrap);
      wrap.style.transformOrigin='top left';
      wrap.style.transform='scale('+s+')';
      wrap.style.width=(100/s)+'%';
    }
  }
  if(document.readyState==='complete') fit();
  else window.addEventListener('load', fit);
})();
</script>
"""


def _inject_fit_script(html: str) -> str:
    """본문(.content)이 안전영역을 넘으면 transform:scale 로 자동 축소해 푸터 겹침을 막는다."""
    if "</body>" in html:
        return html.replace("</body>", _FIT_SCRIPT + "</body>", 1)
    return html + _FIT_SCRIPT


def _inject_background(html: str, bg_data_url: str) -> str:
    """LLM 이 남긴 __BG__ 토큰을 실제 배경 data URL 로 치환.

    토큰이 없으면(LLM 이 규칙 무시) .page background-image 를 강제로 덮어쓴다.
    """
    if "__BG__" in html:
        return html.replace("__BG__", bg_data_url)
    # 폴백: LLM 이 placeholder 배경을 넣었을 때 — .page 의 background-image 를 교체
    html, n = re.subn(
        r"(\.page\s*\{[^}]*?background-image\s*:\s*)url\([^)]*\)",
        lambda m: m.group(1) + f"url('{bg_data_url}')",
        html,
        count=1,
        flags=re.S,
    )
    return html


def _image_size(image_bytes: bytes) -> tuple[int, int]:
    """이미지 바이트 → (width, height). 실패 시 (0, 0)."""
    try:
        import io

        from PIL import Image

        with Image.open(io.BytesIO(image_bytes)) as im:
            return im.width, im.height
    except Exception:  # noqa: BLE001
        return 0, 0


def _decode_data_url(data_url: str) -> tuple[bytes, str]:
    """data URL(또는 raw base64) → (bytes, mime)."""
    mime = "image/png"
    b64 = data_url
    if data_url.startswith("data:"):
        head, _, b64 = data_url.partition(",")
        m = re.match(r"data:([^;]+)", head)
        if m:
            mime = m.group(1)
    return base64.b64decode(b64), mime


def render_pages_html_llm(pages: list[dict[str, Any]]) -> tuple[str, list[str]]:
    """여러 페이지를 각각 LLM 생성한 뒤 page-break 합본 문서로 묶는다. (html, warnings).

    각 프레임은 배경 이미지 비율에 맞춰 크기를 잡는다(배경 없으면 A4).
    """
    warnings: list[str] = []
    frames: list[str] = []
    for i, p in enumerate(pages):
        view = p.get("view") or {}
        ref = p.get("referenceImageDataUrl")
        if not ref:
            warnings.append(f"{i + 1}페이지: 원본 이미지 없음 → LLM 레이아웃 재현 생략")
            continue
        bg = p.get("backgroundImageDataUrl")
        html, w = render_html_llm_from_dataurl(view, ref, bg)
        frames.append(f'<div class="pg"><iframe class="fr" style="{_frame_dims(bg)}" srcdoc="{_attr(html)}"></iframe></div>')
        warnings.extend(f"{i + 1}페이지: {x}" for x in w)
    body = "".join(frames)
    style = (
        "<style>body{margin:0;background:#525659;}"
        ".pg{display:flex;justify-content:center;padding:16px 0;page-break-after:always;}"
        ".fr{border:0;background:#fff;box-shadow:0 2px 16px rgba(0,0,0,.3);}"
        "@media print{body{background:#fff;}.pg{padding:0;}.fr{box-shadow:none;}}</style>"
    )
    doc = f'<!doctype html><html lang="ko"><head><meta charset="utf-8">{style}</head><body>{body}</body></html>'
    return doc, warnings


def _frame_dims(background_data_url: str | None) -> str:
    """배경 비율에 맞춘 프레임 width/height CSS. 배경 없으면 A4."""
    if background_data_url:
        img_bytes, _ = _decode_data_url(background_data_url)
        w, h = _image_size(img_bytes)
        if w and h:
            width_mm = 210
            height_mm = round(width_mm * h / w, 1)
            return f"width:{width_mm}mm;height:{height_mm}mm;"
    return "width:210mm;height:297mm;"


def _attr(html: str) -> str:
    return html.replace("&", "&amp;").replace('"', "&quot;")


def render_page_png(html: str) -> bytes:
    """완성 HTML 문서(단일 페이지) → A4 PNG 바이트. export 하네스의 레이아웃 레퍼런스용."""
    with tempfile.TemporaryDirectory() as tmp:
        html_path = Path(tmp) / "page.html"
        png_path = Path(tmp) / "page.png"
        html_path.write_text(html, encoding="utf-8")
        runner = NODE_DIR / "html_to_png.mjs"
        proc = subprocess.run(
            ["node", str(runner), str(html_path), str(png_path)],
            capture_output=True, text=True, cwd=str(NODE_DIR), timeout=120,
        )
        if proc.returncode != 0 or not png_path.exists():
            raise RuntimeError(f"HTML→PNG 렌더 실패: {proc.stderr or proc.stdout}")
        return png_path.read_bytes()


def render_html_llm_from_dataurl(
    view: dict[str, Any],
    image_data_url: str,
    background_data_url: str | None = None,
) -> tuple[str, list[str]]:
    """data URL(예: data:image/png;base64,...) 입력용 래퍼. 배경 이미지(선택) 별도 전달."""
    image_bytes, mime = _decode_data_url(image_data_url)
    background = _decode_data_url(background_data_url) if background_data_url else None
    return render_html_llm(view, image_bytes, mime, background=background)
