#!/usr/bin/env python3
"""
SEO Fetch — Secure HTTP page fetcher for SEO analysis.

Features:
- SSRF protection (blocks private/loopback/reserved IPs)
- Multi-UA support (standard, Googlebot, GPTBot, ClaudeBot)
- Redirect chain tracking
- Cookie handling
- Configurable timeout

Author: Laurent Rochetta
License: MIT
"""

import argparse
import ipaddress
import json
import socket
import sys
from typing import Optional
from urllib.parse import urljoin, urlparse

try:
    import requests
except ImportError:
    print("Error: requests library required. Install: pip install requests", file=sys.stderr)
    sys.exit(1)


# ── User-Agent Presets ──────────────────────────────────────────────

USER_AGENTS = {
    "default": (
        "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
        "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 BMADSEOEngine/2.0"
    ),
    "googlebot": (
        "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
    ),
    "gptbot": (
        "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.2; "
        "+https://openai.com/gptbot)"
    ),
    "claudebot": (
        "Mozilla/5.0 (compatible; ClaudeBot/1.0; +https://www.anthropic.com/claudebot)"
    ),
    "mobile": (
        "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
        "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
    ),
}

DEFAULT_HEADERS = {
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    "Accept-Language": "en-US,en;q=0.9,fr;q=0.8",
    "Accept-Encoding": "gzip, deflate, br",
    "Connection": "keep-alive",
    "Cache-Control": "no-cache",
}


# ── Security: SSRF Prevention ──────────────────────────────────────

def _ip_is_blocked(ip: "ipaddress._BaseAddress") -> bool:
    """Return True if an IP falls in any range that must never be reached."""
    return bool(
        ip.is_private
        or ip.is_loopback
        or ip.is_reserved
        or ip.is_link_local
        or ip.is_multicast
        or ip.is_unspecified
    )


def is_safe_url(url: str) -> bool:
    """Block requests to private, loopback, and reserved IP addresses.

    Fails CLOSED: a missing host, a non-HTTP(S) scheme, a DNS resolution
    error, or an unparseable/blocked address all cause the URL to be
    rejected. Every resolved address (IPv4 and IPv6) must be public.
    """
    parsed = urlparse(url)
    hostname = parsed.hostname

    if not hostname:
        return False

    if parsed.scheme not in ("http", "https"):
        return False

    try:
        # Resolve ALL IP addresses (IPv4 and IPv6) via getaddrinfo
        addrinfo = socket.getaddrinfo(hostname, None)
    except socket.gaierror:
        return False  # Fail closed: unresolvable host is treated as unsafe

    if not addrinfo:
        return False  # Fail closed: no addresses resolved

    for entry in addrinfo:
        ip_str = entry[4][0]  # sockaddr[0] contains the IP string
        try:
            ip = ipaddress.ip_address(ip_str)
        except ValueError:
            return False  # Fail closed: unparseable address
        # IPv4-mapped IPv6 (::ffff:a.b.c.d) must be checked as its IPv4 form
        mapped = getattr(ip, "ipv4_mapped", None)
        if mapped is not None and _ip_is_blocked(mapped):
            return False
        if _ip_is_blocked(ip):
            return False

    return True


# ── Core Fetcher ───────────────────────────────────────────────────

def fetch_page(
    url: str,
    timeout: int = 30,
    follow_redirects: bool = True,
    max_redirects: int = 5,
    user_agent: str = "default",
) -> dict:
    """
    Fetch a web page with security checks and detailed response tracking.

    Returns dict with: url, status_code, content, headers, redirect_chain,
    content_length, response_time_ms, error
    """
    result = {
        "url": url,
        "final_url": None,
        "status_code": None,
        "content": None,
        "headers": {},
        "redirect_chain": [],
        "content_length": 0,
        "response_time_ms": 0,
        "error": None,
    }

    # Normalize URL
    parsed = urlparse(url)
    if not parsed.scheme:
        url = f"https://{url}"
        parsed = urlparse(url)

    if parsed.scheme not in ("http", "https"):
        result["error"] = f"Invalid URL scheme: {parsed.scheme}"
        return result

    # SSRF check
    if not is_safe_url(url):
        resolved = "unknown"
        try:
            # Use getaddrinfo for consistent multi-address resolution
            addrinfo = socket.getaddrinfo(parsed.hostname, None)
            resolved = ", ".join(set(entry[4][0] for entry in addrinfo))
        except Exception:
            pass
        result["error"] = f"Blocked: URL resolves to private/internal IP ({resolved})"
        return result

    try:
        session = requests.Session()

        headers = dict(DEFAULT_HEADERS)
        ua_string = USER_AGENTS.get(user_agent, user_agent)
        headers["User-Agent"] = ua_string

        import time
        start = time.monotonic()

        # Follow redirects manually so is_safe_url() runs on EVERY hop.
        # Letting requests follow redirects internally would allow a
        # public URL to redirect (302) to an internal/metadata endpoint
        # (redirect-based SSRF), bypassing the initial check.
        current_url = url
        redirect_chain = []
        hops = 0
        response = None

        while True:
            response = session.get(
                current_url,
                headers=headers,
                timeout=timeout,
                allow_redirects=False,
            )

            if not follow_redirects or not response.is_redirect:
                break

            location = response.headers.get("Location")
            if not location:
                break

            next_url = urljoin(current_url, location)
            next_parsed = urlparse(next_url)

            if next_parsed.scheme not in ("http", "https"):
                result["error"] = (
                    f"Blocked redirect to non-HTTP(S) scheme: {next_parsed.scheme}"
                )
                return result

            # Re-validate the redirect target (blocks redirect-based SSRF)
            if not is_safe_url(next_url):
                result["error"] = (
                    f"Blocked: redirect to private/internal URL ({next_url})"
                )
                return result

            hops += 1
            if hops > max_redirects:
                result["error"] = f"Too many redirects (max {max_redirects})"
                return result

            redirect_chain.append(
                {"url": current_url, "status": response.status_code}
            )
            current_url = next_url

        elapsed_ms = round((time.monotonic() - start) * 1000)

        result["final_url"] = current_url
        result["status_code"] = response.status_code
        result["content"] = response.text
        result["headers"] = dict(response.headers)
        result["content_length"] = len(response.content)
        result["response_time_ms"] = elapsed_ms
        result["redirect_chain"] = redirect_chain

    except requests.exceptions.Timeout:
        result["error"] = f"Request timed out after {timeout}s"
    except requests.exceptions.TooManyRedirects:
        result["error"] = f"Too many redirects (max {max_redirects})"
    except requests.exceptions.SSLError as e:
        result["error"] = f"SSL error: {e}"
    except requests.exceptions.ConnectionError as e:
        result["error"] = f"Connection error: {e}"
    except requests.exceptions.RequestException as e:
        result["error"] = f"Request failed: {e}"

    return result


# ── CLI ────────────────────────────────────────────────────────────

def main():
    parser = argparse.ArgumentParser(
        description="SEO Fetch — Secure HTTP fetcher for SEO analysis (BMAD+ SEO Engine)"
    )
    parser.add_argument("url", help="URL to fetch")
    parser.add_argument("--output", "-o", help="Save HTML to file")
    parser.add_argument("--timeout", "-t", type=int, default=30, help="Timeout in seconds")
    parser.add_argument("--no-redirects", action="store_true", help="Don't follow redirects")
    parser.add_argument(
        "--ua", choices=list(USER_AGENTS.keys()), default="default",
        help="User-Agent preset (default, googlebot, gptbot, claudebot, mobile)"
    )
    parser.add_argument("--json", "-j", action="store_true", help="Output full result as JSON")

    args = parser.parse_args()

    result = fetch_page(
        args.url,
        timeout=args.timeout,
        follow_redirects=not args.no_redirects,
        user_agent=args.ua,
    )

    if result["error"]:
        print(f"Error: {result['error']}", file=sys.stderr)
        sys.exit(1)

    if args.json:
        # Output metadata as JSON (without full HTML content for readability)
        output = {k: v for k, v in result.items() if k != "content"}
        output["content_preview"] = result["content"][:500] if result["content"] else None
        print(json.dumps(output, indent=2))
    elif args.output:
        with open(args.output, "w", encoding="utf-8") as f:
            f.write(result["content"])
        print(f"Saved to {args.output}")
    else:
        print(result["content"])

    # Metadata to stderr
    print(f"\n--- Fetch Summary ---", file=sys.stderr)
    print(f"Final URL: {result['final_url']}", file=sys.stderr)
    print(f"Status: {result['status_code']}", file=sys.stderr)
    print(f"Size: {result['content_length']:,} bytes", file=sys.stderr)
    print(f"Time: {result['response_time_ms']}ms", file=sys.stderr)
    if result["redirect_chain"]:
        chain = " → ".join(r["url"] for r in result["redirect_chain"])
        print(f"Redirects: {chain}", file=sys.stderr)


if __name__ == "__main__":
    main()
