# -*- coding: utf-8 -*-
"""
yjm_net_upnp.py

외부 공유 URL 생성 + UPnP 포트포워딩 보조 모듈.

주의:
- UPnP는 라우터에서 켜져 있어야 한다.
- 통신사 CGNAT / 이중 NAT 환경에서는 UPnP가 성공해도 외부 접속이 안 될 수 있다.
- 이 모듈은 포트 매핑만 한다. Windows 방화벽 허용은 별도다.
"""

from __future__ import annotations

import ipaddress
import socket
import urllib.request
from dataclasses import dataclass
from typing import Optional


@dataclass
class PortMappingResult:
    ok: bool
    message: str
    lan_ip: str = ""
    router_external_ip: str = ""
    echo_public_ip: str = ""
    http_external_port: int = 0
    ws_external_port: int = 0


class UpnpPortMapper:
    def __init__(self, logger=None):
        self.logger = logger
        self.upnp = None
        self.mapped_ports: list[tuple[int, str]] = []

    @staticmethod
    def get_lan_ip() -> str:
        """인터넷 연결 시 실제 LAN IP 추정. 실패하면 hostname 기반으로 fallback."""
        try:
            with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
                s.connect(("8.8.8.8", 80))
                return s.getsockname()[0]
        except Exception:
            try:
                return socket.gethostbyname(socket.gethostname())
            except Exception:
                return "127.0.0.1"

    @staticmethod
    def fetch_public_ip(url: str, timeout: float = 4.0) -> str:
        if not url:
            return ""
        try:
            with urllib.request.urlopen(url, timeout=timeout) as r:
                text = r.read(128).decode("utf-8", errors="ignore").strip()
            return text.split()[0]
        except Exception:
            return ""

    @staticmethod
    def is_private_or_cgnat(ip_text: str) -> bool:
        try:
            ip = ipaddress.ip_address(ip_text.strip())
        except Exception:
            return False
        return bool(
            ip.is_private
            or ip.is_loopback
            or ip.is_link_local
            or ip in ipaddress.ip_network("100.64.0.0/10")
        )

    def open_ports(
        self,
        lan_ip: str,
        http_port: int,
        ws_port: int,
        public_ip_url: str = "",
        desc_prefix: str = "yjm_win2rtc",
    ) -> PortMappingResult:
        try:
            import miniupnpc  # type: ignore
        except Exception as exc:
            return PortMappingResult(False, f"miniupnpc import 실패: {exc}", lan_ip=lan_ip)

        try:
            u = miniupnpc.UPnP()
            u.discoverdelay = 200
            found = u.discover()
            if self.logger:
                self.logger.info("UPnP discover: %s", found)
            u.selectigd()
            self.upnp = u

            router_external_ip = ""
            try:
                router_external_ip = str(u.externalipaddress())
            except Exception:
                router_external_ip = ""

            # miniupnpc가 알려주는 LAN 주소가 있으면 우선 사용한다.
            upnp_lan_ip = getattr(u, "lanaddr", "") or lan_ip
            lan_ip = str(upnp_lan_ip)

            for local_port, label in [(http_port, "http"), (ws_port, "ws")]:
                ext_port = int(local_port)
                # 이전 잔여 매핑이 있으면 삭제 후 재등록한다. 실패해도 계속 시도한다.
                try:
                    u.deleteportmapping(ext_port, "TCP")
                except Exception:
                    pass

                ok = u.addportmapping(
                    ext_port,
                    "TCP",
                    lan_ip,
                    int(local_port),
                    f"{desc_prefix}-{label}",
                    "",
                )
                if not ok:
                    return PortMappingResult(
                        False,
                        f"UPnP {label} 포트 매핑 실패: external {ext_port} -> {lan_ip}:{local_port}",
                        lan_ip=lan_ip,
                        router_external_ip=router_external_ip,
                        http_external_port=http_port,
                        ws_external_port=ws_port,
                    )
                self.mapped_ports.append((ext_port, "TCP"))

            echo_ip = self.fetch_public_ip(public_ip_url) if public_ip_url else ""
            msg_parts = [f"UPnP 성공: {lan_ip}, HTTP {http_port}, WS {ws_port}"]
            if router_external_ip:
                msg_parts.append(f"router_ip={router_external_ip}")
            if echo_ip:
                msg_parts.append(f"echo_ip={echo_ip}")
            if router_external_ip and self.is_private_or_cgnat(router_external_ip):
                msg_parts.append("주의: router_external_ip가 사설/CGNAT 대역입니다")
            if echo_ip and router_external_ip and echo_ip != router_external_ip:
                msg_parts.append("주의: echo IP와 router IP가 다릅니다. 이중 NAT/CGNAT 가능성")

            return PortMappingResult(
                True,
                " / ".join(msg_parts),
                lan_ip=lan_ip,
                router_external_ip=router_external_ip,
                echo_public_ip=echo_ip,
                http_external_port=http_port,
                ws_external_port=ws_port,
            )
        except Exception as exc:
            return PortMappingResult(False, f"UPnP 실패: {exc}", lan_ip=lan_ip)

    def close_ports(self) -> str:
        if not self.upnp:
            return "닫을 UPnP 매핑이 없습니다"
        messages = []
        for port, proto in list(self.mapped_ports):
            try:
                self.upnp.deleteportmapping(int(port), proto)
                messages.append(f"닫음 {proto}/{port}")
            except Exception as exc:
                messages.append(f"닫기 실패 {proto}/{port}: {exc}")
        self.mapped_ports.clear()
        return ", ".join(messages) if messages else "닫을 UPnP 매핑이 없습니다"


def make_viewer_url(http_host: str, http_port: int, ws_host: str, ws_port: int, token: str = "") -> str:
    """viewer URL 생성.

    주의:
    - WebSocket 인증용 token은 ws= 내부 URL에도 들어간다.
    - HTTP 업로드/transfer-status 인증용 token은 viewer.html의 바깥 query에도 별도로 넣는다.

    이전 버전은 ws= 내부에만 token을 넣어서 화면/제어/클립보드는 되지만,
    파일 업로드 POST /upload?token=... 에 사용할 token을 viewer가 못 찾는 문제가 있었다.
    """
    from urllib.parse import urlencode

    ws_url = f"ws://{ws_host}:{int(ws_port)}"
    if token:
        ws_url += "?" + urlencode({"token": token})

    query = {"ws": ws_url}
    if token:
        query["token"] = token
    return f"http://{http_host}:{int(http_port)}/viewer.html?{urlencode(query)}"
