#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations

import argparse
import gzip
import html.parser
import ipaddress
import json
import os
import socket
import ssl
import sys
import time
import zlib
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urljoin, urlparse

MAX_BODY_BYTES = 2_000_000
TIMEOUT = 12.0


@dataclass
class FetchResult:
    url: str
    status: int
    headers: dict[str, str]
    body: bytes
    timings: dict[str, float]
    ips: list[str]
    nameservers: list[str]
    cnames: list[str]
    error: str | None = None


class ResourceParser(html.parser.HTMLParser):
    def __init__(self, base_url: str):
        super().__init__(convert_charrefs=True)
        self.base_url = base_url
        self.in_title = False
        self.title_parts: list[str] = []
        self.scripts: list[str] = []
        self.stylesheets: list[str] = []
        self.images: list[str] = []
        self.media: list[str] = []

    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
        attr = {k.lower(): v or "" for k, v in attrs}
        tag = tag.lower()
        if tag == "title":
            self.in_title = True
        elif tag == "script" and attr.get("src"):
            self.scripts.append(urljoin(self.base_url, attr["src"]))
        elif tag == "link" and attr.get("href") and "stylesheet" in attr.get("rel", "").lower():
            self.stylesheets.append(urljoin(self.base_url, attr["href"]))
        elif tag == "img" and attr.get("src"):
            self.images.append(urljoin(self.base_url, attr["src"]))
        elif tag in {"video", "source"} and attr.get("src"):
            self.media.append(urljoin(self.base_url, attr["src"]))

    def handle_endtag(self, tag: str) -> None:
        if tag.lower() == "title":
            self.in_title = False

    def handle_data(self, data: str) -> None:
        if self.in_title:
            self.title_parts.append(data)

    @property
    def title(self) -> str:
        return " ".join("".join(self.title_parts).split())


def normalize_url(raw: str) -> str:
    raw = raw.strip()
    if not raw:
        raise ValueError("URL을 입력하세요.")
    if "://" not in raw:
        raw = "https://" + raw
    parsed = urlparse(raw)
    if parsed.scheme not in {"http", "https"} or not parsed.hostname:
        raise ValueError("http 또는 https URL만 분석할 수 있습니다.")
    path = parsed.path or "/"
    query = f"?{parsed.query}" if parsed.query else ""
    port = f":{parsed.port}" if parsed.port else ""
    return f"{parsed.scheme}://{parsed.hostname}{port}{path}{query}"


def is_public_ip(ip: str) -> bool:
    try:
        obj = ipaddress.ip_address(ip)
    except ValueError:
        return False
    return not (obj.is_private or obj.is_loopback or obj.is_link_local or obj.is_multicast or obj.is_reserved or obj.is_unspecified)


def doh_query(host: str, record_type: str) -> list[dict[str, Any]]:
    path = f"/dns-query?name={host}&type={record_type}"
    req = (
        f"GET {path} HTTP/1.1\r\n"
        "Host: cloudflare-dns.com\r\n"
        "User-Agent: SiteSpeedCLI/1.0\r\n"
        "Accept: application/dns-json\r\n"
        "Connection: close\r\n\r\n"
    )
    try:
        with socket.create_connection(("1.1.1.1", 443), timeout=5.0) as sock:
            ctx = ssl.create_default_context()
            with ctx.wrap_socket(sock, server_hostname="cloudflare-dns.com") as conn:
                conn.settimeout(5.0)
                conn.sendall(req.encode("ascii"))
                chunks = []
                while True:
                    chunk = conn.recv(65536)
                    if not chunk:
                        break
                    chunks.append(chunk)
    except Exception:
        return []
    _, _, body = b"".join(chunks).partition(b"\r\n\r\n")
    try:
        return json.loads(body.decode("utf-8", errors="replace")).get("Answer", [])
    except Exception:
        return []


def dns_details(host: str, port: int) -> tuple[list[str], list[str], list[str], float]:
    start = time.perf_counter()
    ips: list[str] = []
    try:
        infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
        ips = [info[4][0] for info in infos]
    except socket.gaierror:
        pass

    nameservers: list[str] = []
    cnames: list[str] = []
    if not ips:
        for ans in doh_query(host, "A") + doh_query(host, "AAAA"):
            if ans.get("type") in (1, 28) and ans.get("data"):
                ips.append(str(ans["data"]))
    for ans in doh_query(host, "NS"):
        if ans.get("type") == 2 and ans.get("data"):
            nameservers.append(str(ans["data"]).rstrip("."))
    for ans in doh_query(host, "CNAME"):
        if ans.get("type") == 5 and ans.get("data"):
            cnames.append(str(ans["data"]).rstrip("."))

    dns_ms = (time.perf_counter() - start) * 1000
    ips = list(dict.fromkeys([ip for ip in ips if is_public_ip(ip)]))
    return ips, list(dict.fromkeys(nameservers)), list(dict.fromkeys(cnames)), dns_ms


def parse_response(raw: bytes) -> tuple[int, dict[str, str], bytes]:
    header_blob, _, body = raw.partition(b"\r\n\r\n")
    lines = header_blob.decode("iso-8859-1", errors="replace").splitlines()
    status = 0
    if lines:
        parts = lines[0].split()
        if len(parts) >= 2 and parts[1].isdigit():
            status = int(parts[1])
    headers: dict[str, str] = {}
    for line in lines[1:]:
        if ":" in line:
            key, value = line.split(":", 1)
            key = key.strip().lower()
            headers[key] = value.strip()
    return status, headers, body


def decode_body(body: bytes, encoding: str) -> bytes:
    try:
        if "gzip" in encoding.lower():
            return gzip.decompress(body)
        if "deflate" in encoding.lower():
            return zlib.decompress(body)
    except Exception:
        pass
    return body


def fetch(url: str) -> FetchResult:
    overall = time.perf_counter()
    parsed = urlparse(url)
    host = parsed.hostname or ""
    scheme = parsed.scheme
    port = parsed.port or (443 if scheme == "https" else 80)
    path = parsed.path or "/"
    if parsed.query:
        path += "?" + parsed.query
    ips, ns, cnames, dns_ms = dns_details(host, port)
    if not ips:
        raise ValueError("DNS 조회에 실패했거나 공개 IP를 찾지 못했습니다.")
    ip = ips[0]
    timings: dict[str, float] = {"dns_ms": dns_ms}
    req_start = time.perf_counter()
    try:
        with socket.create_connection((ip, port), timeout=TIMEOUT) as sock:
            timings["connect_ms"] = (time.perf_counter() - req_start) * 1000
            conn: socket.socket | ssl.SSLSocket = sock
            if scheme == "https":
                tls_start = time.perf_counter()
                ctx = ssl.create_default_context()
                conn = ctx.wrap_socket(sock, server_hostname=host)
                timings["tls_ms"] = (time.perf_counter() - tls_start) * 1000
            request = (
                f"GET {path} HTTP/1.1\r\n"
                f"Host: {host}\r\n"
                "User-Agent: SiteSpeedCLI/1.0\r\n"
                "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\r\n"
                "Accept-Encoding: gzip, deflate\r\n"
                "Connection: close\r\n\r\n"
            )
            conn.settimeout(TIMEOUT)
            conn.sendall(request.encode("ascii"))
            chunks: list[bytes] = []
            first = True
            total = 0
            while True:
                chunk = conn.recv(65536)
                if first:
                    timings["ttfb_ms"] = (time.perf_counter() - req_start) * 1000
                    first = False
                if not chunk:
                    break
                total += len(chunk)
                if total <= MAX_BODY_BYTES + 65536:
                    chunks.append(chunk)
            timings["request_ms"] = (time.perf_counter() - req_start) * 1000
            timings["total_ms"] = (time.perf_counter() - overall) * 1000
    except Exception as exc:
        timings["request_ms"] = (time.perf_counter() - req_start) * 1000
        timings["total_ms"] = (time.perf_counter() - overall) * 1000
        return FetchResult(url, 0, {}, b"", timings, ips, ns, cnames, str(exc))
    status, headers, body = parse_response(b"".join(chunks))
    body = decode_body(body[:MAX_BODY_BYTES], headers.get("content-encoding", ""))
    return FetchResult(url, status, headers, body, timings, ips, ns, cnames)


def analyze_html(url: str, body: bytes, content_type: str) -> dict[str, Any]:
    if "html" not in content_type.lower() and b"<html" not in body[:300].lower():
        return {"title": "", "css_count": 0, "script_count": 0, "image_count": 0, "media_count": 0, "hosts": []}
    parser = ResourceParser(url)
    parser.feed(body.decode("utf-8", errors="replace"))
    urls = parser.stylesheets + parser.scripts + parser.images + parser.media
    return {
        "title": parser.title,
        "css_count": len(parser.stylesheets),
        "script_count": len(parser.scripts),
        "image_count": len(parser.images),
        "media_count": len(parser.media),
        "hosts": sorted({urlparse(item).hostname or "" for item in urls if urlparse(item).hostname}),
    }


def build_data(url: str) -> dict[str, Any]:
    result = fetch(normalize_url(url))
    resources = analyze_html(result.url, result.body, result.headers.get("content-type", ""))
    issues = []
    if result.status == 0:
        issues.append({"level": "bad", "title": "HTTP 요청 실패", "body": "대상 사이트에 연결하지 못했습니다."})
        if result.error:
            issues.append({"level": "bad", "title": "연결 오류 상세", "body": result.error})
    if result.timings.get("dns_ms", 0) >= 1000:
        issues.append({"level": "bad", "title": "DNS 조회 지연", "body": "DNS 조회가 1초를 초과했습니다."})
    elif result.timings.get("dns_ms", 0) >= 300:
        issues.append({"level": "warn", "title": "DNS 조회 시간 높음", "body": "첫 방문자 체감 속도에 영향을 줄 수 있습니다."})
    if result.status:
        if result.timings.get("ttfb_ms", 0) >= 2000:
            issues.append({"level": "bad", "title": "서버 TTFB 지연", "body": "PHP, DB, CMS, 캐시 설정을 점검하세요."})
        elif result.timings.get("ttfb_ms", 0) >= 800:
            issues.append({"level": "warn", "title": "TTFB 개선 필요", "body": "동적 페이지 캐시와 DB 쿼리 점검을 권장합니다."})
        if not result.headers.get("content-encoding"):
            issues.append({"level": "warn", "title": "압축 미확인", "body": "gzip/brotli 압축 설정을 점검하세요."})
        if not result.headers.get("cache-control"):
            issues.append({"level": "warn", "title": "캐시 정책 미흡", "body": "공개 페이지 캐시 정책을 확인하세요."})
    if resources["script_count"] > 25 or resources["css_count"] > 10:
        issues.append({"level": "warn", "title": "CSS/JS 요청 과다", "body": "불필요한 스크립트 제거와 지연 로드를 검토하세요."})
    if not issues:
        issues.append({"level": "good", "title": "치명적 병목 미확인", "body": "외부 관측 기준으로 큰 병목은 확인되지 않았습니다."})
    return {
        "url": result.url,
        "http_code": result.status,
        "error": result.error,
        "title": resources["title"],
        "timings": result.timings,
        "headers": result.headers,
        "dns": {"ips": result.ips, "nameservers": result.nameservers, "cnames": result.cnames},
        "resources": resources,
        "issues": issues,
    }


def fmt_ms(value: Any) -> str:
    try:
        n = float(value)
    except Exception:
        return "측정 불가"
    return "측정 불가" if n <= 0 else f"{n:,.0f}ms"


def state(value: Any, warn: float, bad: float) -> str:
    try:
        n = float(value)
    except Exception:
        return "bad"
    if n <= 0:
        return "bad"
    if n >= bad:
        return "bad"
    if n >= warn:
        return "warn"
    return "good"


def ko(st: str) -> str:
    return {"good": "안정", "warn": "주의", "bad": "위험"}.get(st, "확인")


def report(data: dict[str, Any], advanced: bool = False) -> str:
    t = data["timings"]
    code = int(data.get("http_code") or 0)
    dns_s = state(t.get("dns_ms"), 300, 1000)
    ttfb_s = "bad" if not code else state(t.get("ttfb_ms"), 800, 2000)
    total_s = "bad" if not code else state(t.get("total_ms"), 2000, 5000)
    bad = sum(1 for issue in data["issues"] if issue["level"] == "bad")
    warn = sum(1 for issue in data["issues"] if issue["level"] == "warn")
    score = min(100, bad * 30 + warn * 12 + (20 if dns_s == "bad" else 0) + (24 if ttfb_s == "bad" else 0) + (24 if total_s == "bad" else 0) + (25 if not code or code >= 500 else 0))
    level = "bad" if score >= 60 else ("warn" if score >= 30 else "good")
    lines = [
        "SiteSpeed 고급 진단 보고서",
        "=" * 36,
        f"URL        : {data['url']}",
        f"종합 등급  : {ko(level)} ({score}/100)",
    ]
    if data.get("error"):
        lines.append(f"연결 오류  : {data['error']}")
    lines += [
        "",
        "기준값 비교",
        "-" * 36,
        f"DNS 조회   안정 0~300ms      주의 300~999ms       위험 1,000ms 이상   측정 {fmt_ms(t.get('dns_ms'))} [{ko(dns_s)}]",
        f"TTFB       안정 0~800ms      주의 800~1,999ms     위험 2,000ms 이상   측정 {fmt_ms(t.get('ttfb_ms'))} [{ko(ttfb_s)}]",
        f"총 응답    안정 0~2,000ms    주의 2,000~4,999ms   위험 5,000ms 이상   측정 {fmt_ms(t.get('total_ms'))} [{ko(total_s)}]",
        f"HTTP       안정 200~399      주의 400대           위험 0 또는 500대    측정 {code or '연결 실패'}",
        "",
        "진단 결과",
        "-" * 36,
    ]
    for issue in data["issues"]:
        lines.append(f"- [{ko(issue['level'])}] {issue['title']}: {issue['body']}")
    dns = data["dns"]
    res = data["resources"]
    lines += [
        "",
        "기본 정보",
        "-" * 36,
        f"IP         : {', '.join(dns['ips']) or '-'}",
        f"NS         : {', '.join(dns['nameservers']) or '-'}",
        f"CNAME      : {', '.join(dns['cnames']) or '-'}",
        f"Title      : {data.get('title') or '-'}",
        "",
        "리소스",
        "-" * 36,
        f"CSS        : {res['css_count']}",
        f"Script     : {res['script_count']}",
        f"Image      : {res['image_count']}",
        f"Media      : {res['media_count']}",
        f"Hosts      : {', '.join(res['hosts']) or '-'}",
    ]
    if advanced:
        lines += ["", "응답 헤더", "-" * 36]
        lines += [f"{k}: {v}" for k, v in sorted(data["headers"].items())] or ["-"]
    lines += ["", "이온디호스팅 이전 상담: https://eond.com/hosting / eond@eond.com"]
    return "\n".join(lines)


def main(argv: list[str] | None = None) -> int:
    p = argparse.ArgumentParser(prog="sitespeed", description="웹호스팅 서버에서 실행하는 사이트 속도 고급 진단 CLI")
    p.add_argument("url", help="분석할 주소. https://, http://는 붙여도 되고 생략해도 됩니다.")
    p.add_argument("-a", "--advanced", action="store_true", help="응답 헤더까지 출력")
    p.add_argument("--json", action="store_true", help="JSON 출력")
    p.add_argument("-o", "--output", help="보고서 저장 경로")
    args = p.parse_args(argv)
    try:
        data = build_data(args.url)
    except Exception as exc:
        print(f"분석 실패: {exc}", file=sys.stderr)
        return 2
    text = json.dumps(data, ensure_ascii=False, indent=2) if args.json else report(data, args.advanced)
    if args.output:
        Path(args.output).write_text(text, encoding="utf-8")
        print(f"저장 완료: {args.output}")
    else:
        print(text)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
