"""GROUNDING-V2 §4b — **인용이 그 페이지에 실제로 있나**(citation support).

★★소유권(ownership)과 **다른 축**이다 (Codex).

    ownership  이 주소를 **누가 찾았나** — `batch=1` claim URL + 공식
               `action.sources`/`url_citation`
    support    그 주소의 **본문에 인용이 실제로 있나** — 여기가 그것이다

★provider 는 본문(snippet)을 안 준다. 그래서 **그 URL 을 일반 HTTP 로 열어**
직접 확인한다 — provider 계약에 안 기댄다.

★★**빼면 안 된다.** 이걸 빼면 모델이 **지어낸 URL·인용도 통과**하고 그대로
정본이 된다 (Codex). 확인 못 한 것은 「문제없음」이 아니라 **미확정**이다.

★유료 API 가 아니다. 다만 바깥 네트워크를 쓰므로 시험에서는 fetcher 를 갈아
끼운다 — provider 호출 직전 한 겹만 막는다.
"""
from __future__ import annotations

import re
import unicodedata
import urllib.parse
from typing import Any, Callable, Dict, List, Optional, Sequence

#: 판정. ★「못 열었다」와 「열었는데 없다」를 **갈라 센다** — 고칠 곳이 다르다.
S_FOUND = "있다"
S_ABSENT = "없다"          # 열었는데 그 문장이 없다. ★지어낸 인용일 수도,
#:                          링크가 죽어 다른 화면이 온 것일 수도 있다 — 실측으로
#:                          후자가 있었다. **단정하지 않는다.**
S_UNREACHABLE = "못 열었다"  # 차단·오류·빈 본문 — 미확정
SUPPORTS = (S_FOUND, S_ABSENT, S_UNREACHABLE)

#: ★한 페이지에서 읽을 최대 바이트. 본문이 아무리 길어도 여기서 자른다 —
#:  ★이건 **원문을 자르는 것이 아니다**(LLM 에 안 보낸다). 우리가 대조할
#:  범위이고, 넘치면 「못 찾았다」가 아니라 그 사실을 남긴다.
MAX_PAGE_BYTES = 2_000_000

_TAG = re.compile(r"<(script|style)[^>]*>.*?</\1>", re.S | re.I)
_ANY_TAG = re.compile(r"<[^>]+>")
_WS = re.compile(r"\s+")
#: ★NFKC 뒤에는 `…` 가 `...` 다. 둘 다로 자른다.
_ELLIPSIS = re.compile(r"\.{3}|…")


def normalize(text: Any) -> str:
    """대조용 정규화 — 태그 제거 · NFKC · 공백 축약 · 소문자.

    ★뜻으로 묶지 않는다. **글자가 그대로 있는지**만 본다.
    """
    t = _TAG.sub(" ", text or "")
    t = _ANY_TAG.sub(" ", t)
    t = unicodedata.normalize("NFKC", t)
    return " ".join(t.lower().split())


#: ★★**SSRF 경계.** 모델이 낸 URL 을 서버가 직접 여는 경로다 — 그대로 열면
#:  내부망·메타데이터 주소를 우리 손으로 긁어 온다 (Codex BLOCK).
ALLOWED_SCHEMES = ("http", "https")
CONNECT_TIMEOUT = 5.0
READ_TIMEOUT = 10.0
MAX_REDIRECTS = 3


class UnsafeUrl(ValueError):
    """열면 안 되는 주소. ★사유를 담아 **미확정**으로 센다."""


def _assert_safe(url: str) -> str:
    """주소 하나를 검사한다. ★DNS 를 **풀어서** 본다 — 이름만 보면 못 막는다."""
    import ipaddress
    import socket
    import urllib.parse

    u = urllib.parse.urlsplit(url)
    if u.scheme not in ALLOWED_SCHEMES:
        raise UnsafeUrl(f"scheme 이 {u.scheme!r} 다 — http/https 만 연다")
    if u.username or u.password:
        # ★`user:pass@host` 는 자격 증명이 URL 에 실려 나가는 길이다.
        raise UnsafeUrl("userinfo 가 있다")
    host = u.hostname or ""
    if not host:
        raise UnsafeUrl("host 가 없다")
    try:
        infos = socket.getaddrinfo(host, u.port or (443 if u.scheme == "https"
                                                    else 80),
                                   proto=socket.IPPROTO_TCP)
    except OSError as exc:
        raise UnsafeUrl(f"이름을 못 푼다: {exc}") from exc
    for info in infos:
        ip = ipaddress.ip_address(info[4][0])
        # ★**하나라도** 안전하지 않으면 막는다 — DNS 가 여러 개를 준다.
        if (ip.is_private or ip.is_loopback or ip.is_link_local
                or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
            raise UnsafeUrl(f"내부/예약 주소로 풀린다: {ip}")
    return url


def _check_peer(sock: Any) -> None:
    """**실제로 연결된 상대 주소**를 본다. ★이게 없으면 DNS rebinding 이 뚫린다.

    ★★검사할 때 푼 주소와 **연결할 때 푸는 주소는 다를 수 있다.** 공격자가
    가진 DNS 는 첫 조회에 공개 주소를, 두 번째에 내부 주소를 줄 수 있다
    (TOCTOU / DNS rebinding). 그래서 **붙은 뒤에 상대를 다시 본다** (Codex).
    """
    import ipaddress

    peer = sock.getpeername()
    ip = ipaddress.ip_address(peer[0])
    if (ip.is_private or ip.is_loopback or ip.is_link_local
            or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
        try:
            sock.close()
        finally:
            raise UnsafeUrl(f"연결된 상대가 내부/예약 주소다: {ip}")


def _guarded_opener():
    """붙은 뒤 상대 주소를 확인하는 opener."""
    import http.client
    import urllib.request

    class _HTTP(http.client.HTTPConnection):
        def connect(self):
            super().connect()
            _check_peer(self.sock)

    class _HTTPS(http.client.HTTPSConnection):
        def connect(self):
            super().connect()
            _check_peer(self.sock)

    class _HTTPHandler(urllib.request.HTTPHandler):
        def http_open(self, req):
            return self.do_open(_HTTP, req)

    class _HTTPSHandler(urllib.request.HTTPSHandler):
        def https_open(self, req):
            return self.do_open(_HTTPS, req)

    class _NoRedirect(urllib.request.HTTPRedirectHandler):
        def redirect_request(self, req, fp, code, msg, headers, newurl):
            # ★자동으로 따라가지 않는다 — **한 홉마다 다시 검사**해야 한다.
            raise _Redirect(newurl)

    # ★★★**proxy 를 쓰면 우리가 검사한 IP 로 안 간다.** 연결은 proxy 로
    #  붙으므로 `getpeername()` 은 proxy 를 가리키고, 최종 목적지는 검증되지
    #  않는다 — 그러면 이 경계 전체가 무의미하다 (Codex).
    #  그래서 ①환경에 proxy 가 있으면 **선다** ②opener 에 `ProxyHandler({})`
    #  를 끼워 **아무것도 안 거치게** 한다.
    proxies = urllib.request.getproxies()
    if proxies:
        raise UnsafeUrl(
            f"proxy 가 설정돼 있다({sorted(proxies)}) — 최종 목적지 IP 를 "
            "검증할 수 없다. 여기서 **선다**")
    return urllib.request.build_opener(
        urllib.request.ProxyHandler({}),
        _HTTPHandler, _HTTPSHandler, _NoRedirect)


class _TooBig(Exception):
    """상한을 넘었다. ★「없다」가 아니라 **미확정**이다 — 뒤쪽에 있었을 수 있다."""


class _Redirect(Exception):
    def __init__(self, to: str):
        self.to = to


def http_fetch(url: str) -> Optional[str]:
    """페이지 본문. 못 열거나 **안전하지 않으면** ``None``.

    ★유료 API 가 아니다. 다만 **모델이 낸 주소**를 여는 것이라 SSRF 경계를
    지난다 — scheme·userinfo·DNS 해석 결과·**연결된 상대 주소**·redirect 마다
    재검증.
    """
    import urllib.error
    import urllib.request

    try:
        opener = _guarded_opener()
    except UnsafeUrl:
        # ★proxy 가 있으면 **한 페이지도 안 연다**.
        return None
    seen, cur = 0, url
    while True:
        try:
            _assert_safe(cur)
        except UnsafeUrl:
            return None
        req = urllib.request.Request(cur, headers={
            "User-Agent": "grounding-claims-support/1.0"})
        try:
            with opener.open(req, timeout=READ_TIMEOUT) as r:
                # ★★**상한+1 을 읽어 잘렸는지 안다.** 딱 상한만 읽으면 뒤쪽에
                #  있던 인용을 「없다」로 오판한다 (Codex).
                raw = r.read(MAX_PAGE_BYTES + 1)
                if len(raw) > MAX_PAGE_BYTES:
                    raise _TooBig()
                # ★★인코딩을 **응답이 말한 것**으로 읽는다. UTF-8 로 못박으면
                #  구형 한국어 페이지(EUC-KR 등)가 깨져 「없다」가 된다.
                enc = (r.headers.get_content_charset() or "").strip() or "utf-8"
                try:
                    return raw.decode(enc, errors="replace")
                except LookupError:
                    return raw.decode("utf-8", errors="replace")
        except _Redirect as red:
            seen += 1
            if seen > MAX_REDIRECTS:
                return None
            cur = urllib.parse.urljoin(cur, red.to)
            continue
        except _TooBig:
            # ★잘린 본문으로 「없다」를 말하지 않는다 — 못 열었다로 센다.
            return None
        except UnsafeUrl:
            # ★붙고 나서 걸린 것 — 본문을 안 읽고 버린다.
            return None
        except (urllib.error.URLError, OSError, ValueError):
            return None


def check_claim(claim: Dict[str, Any], *,
                allowed_urls: Optional[Sequence[str]] = None,
                fetch: Callable[[str], Optional[str]] = http_fetch,
                cache: Optional[Dict[str, Optional[str]]] = None
                ) -> Dict[str, Any]:
    """claim 하나. ★**자기가 인용한 주소**의 본문에서만 찾는다.

    ★★``allowed_urls`` — **공식 경로**(`action.sources` · `url_citation`)에
    실제로 있던 주소만 연다 (Codex). 모델이 문장에 적어 넣은 주소를 그대로
    열면, 모델이 우리 서버에게 **아무 데나 요청을 시킬 수 있다**.
    """
    span = normalize(claim.get("evidence_span"))
    urls = [str(u) for u in (claim.get("sources") or []) if str(u or "").strip()]
    if not span:
        return {"verdict": S_UNREACHABLE, "why": "인용 조각이 없다", "urls": urls}
    if not urls:
        return {"verdict": S_UNREACHABLE, "why": "출처가 없다", "urls": []}
    if allowed_urls is not None:
        ok = {str(u) for u in allowed_urls}
        outside = [u for u in urls if u not in ok]
        if outside:
            # ★**임의 fallback 없다.** 공식 목록 밖이면 그 claim 은 미확정이다 —
            #  다른 주소로 대신 확인해 주지 않는다.
            return {"verdict": S_UNREACHABLE, "urls": urls,
                    "why": ("공식 출처 목록에 없는 주소다 — 열지 않는다: "
                            f"{outside[:2]}")}

    cache = cache if cache is not None else {}
    tried: List[Dict[str, str]] = []
    for u in urls:
        if u not in cache:
            cache[u] = fetch(u)
        body = cache[u]
        if body is None:
            tried.append({"url": u, "how": S_UNREACHABLE})
            continue
        page = normalize(body)
        if not page:
            tried.append({"url": u, "how": "본문이 비었다"})
            continue
        # ★★공백을 **다 뺀 판**으로도 본다. 태그를 지우면 그 자리에 공백이
        #  생겨(`…이후<b>전후면…` → `이후 전후면`) 낱말 가운데가 갈린다 —
        #  그건 인용이 없는 것이 아니라 **우리가 만든 틈**이다.
        #  ★뜻으로 묶는 것이 아니다. 글자가 그대로 있는지만 본다.
        tight_page = _WS.sub("", page)
        tight_span = _WS.sub("", span)
        # ★모델이 「…」로 줄여 쓴 판이 실제로 있었다 — 앞부분만이라도 본다.
        # ★NFKC 가 `…` 를 `...` 로 바꾼다 — **정규화 뒤의 모양**으로 자른다.
        head = _WS.sub("", _ELLIPSIS.split(span)[0])
        if span in page or (tight_span and tight_span in tight_page):
            return {"verdict": S_FOUND, "why": "본문에 그대로 있다",
                    "url": u, "urls": urls, "matched": "full"}
        if len(head) >= 12 and head in tight_page:
            return {"verdict": S_FOUND, "why": "앞부분이 본문에 있다",
                    "url": u, "urls": urls, "matched": "head"}
        tried.append({"url": u, "how": S_ABSENT})

    # ★★**하나라도 못 열었으면 「없다」로 못 잡는다** — 차단된 페이지에
    #  있었을 수 있다. 미확정이다.
    if any(t["how"] != S_ABSENT for t in tried):
        return {"verdict": S_UNREACHABLE,
                "why": "인용한 주소 중 열지 못한 것이 있다", "urls": urls,
                "tried": tried}
    return {"verdict": S_ABSENT,
            "why": "인용한 주소를 다 열었는데 그 문장이 없다 — 지어낸 인용일 수 있다",
            "urls": urls, "tried": tried}


def check_batch(batch: Dict[str, Any], *,
                fetch: Callable[[str], Optional[str]] = http_fetch,
                cache: Optional[Dict[str, Optional[str]]] = None
                ) -> Dict[str, Any]:
    """한 호출의 claim 전부. ★개수 그대로 센다."""
    cache = cache if cache is not None else {}
    rows: List[Dict[str, Any]] = []
    # ★★공식 경로로 실제로 온 주소만 연다 — `action.sources` + `url_citation`.
    allowed = [str(s.get("url")) for s in (batch.get("sources") or [])
               if str(s.get("url") or "").strip()]
    allowed += [u for u in (batch.get("citations") or []) if u not in allowed]
    parsed = batch.get("parsed")
    if isinstance(parsed, dict):
        for r in (parsed.get("results") or []):
            if not isinstance(r, dict):
                continue
            sid = str(r.get("research_subject_id") or "")
            for c in (r.get("claims") or []):
                if not isinstance(c, dict):
                    continue
                got = check_claim(c, allowed_urls=allowed, fetch=fetch,
                                  cache=cache)
                rows.append({"research_subject_id": sid,
                             "statement": str(c.get("statement_native") or "")[:80],
                             **got})
    return {
        "claims": rows,
        "counts": {v: sum(1 for r in rows if r["verdict"] == v)
                   for v in SUPPORTS},
        # ★★**「없다」가 하나라도 있으면 그 호출은 통과가 아니다** — 지어낸
        #  인용이 정본에 들어가는 길이다.
        # ★★**「지어냈다」로 단정하지 않는다.** 실측(2026-08-30): 「없다」가 난
        #  주소를 직접 열어 보니 그 기사가 아니라 **잡지 첫 화면**이었다 —
        #  링크가 죽은 것일 수 있다. 우리가 아는 것은 「그 주소에서 그 문장을
        #  못 찾았다」까지다. 지어낸 것인지는 사람이 본다.
        "has_unsupported": any(r["verdict"] == S_ABSENT for r in rows),
        "note": ("확인 못 한 것은 「문제없음」이 아니라 **미확정**이다. "
                 "사람이 눈으로 보고 통과로 올릴 수 없다"),
    }


def check_run(batches: Sequence[Dict[str, Any]], *,
              fetch: Callable[[str], Optional[str]] = http_fetch
              ) -> Dict[str, Any]:
    """주행 전체. ★페이지는 **한 번만** 받는다(같은 주소를 여러 claim 이 씀)."""
    cache: Dict[str, Optional[str]] = {}
    rows: List[Dict[str, Any]] = []
    for b in batches:
        rows.extend(check_batch(b, fetch=fetch, cache=cache)["claims"])
    return {
        "claims": rows,
        "counts": {v: sum(1 for r in rows if r["verdict"] == v)
                   for v in SUPPORTS},
        # ★★**「지어냈다」로 단정하지 않는다.** 실측(2026-08-30): 「없다」가 난
        #  주소를 직접 열어 보니 그 기사가 아니라 **잡지 첫 화면**이었다 —
        #  링크가 죽은 것일 수 있다. 우리가 아는 것은 「그 주소에서 그 문장을
        #  못 찾았다」까지다. 지어낸 것인지는 사람이 본다.
        "has_unsupported": any(r["verdict"] == S_ABSENT for r in rows),
        "pages_fetched": len(cache),
        "note": ("확인 못 한 것은 「문제없음」이 아니라 **미확정**이다. "
                 "사람이 눈으로 보고 통과로 올릴 수 없다"),
    }
