"""Technical SEO audit: crawls the site politely and scores each page."""
from __future__ import annotations
import re
import time
import urllib.parse
import urllib.robotparser
from dataclasses import dataclass, field

import requests
from bs4 import BeautifulSoup


@dataclass
class PageAudit:
    url: str
    status: int = 0
    issues: list = field(default_factory=list)
    warnings: list = field(default_factory=list)
    passed: list = field(default_factory=list)
    data: dict = field(default_factory=dict)

    @property
    def score(self) -> float:
        total = len(self.issues) * 2 + len(self.warnings) + len(self.passed)
        if total == 0:
            return 0.0
        return round(100 * len(self.passed) / total, 1)


class SiteCrawler:
    def __init__(self, cfg: dict):
        self.cfg = cfg["crawl"]
        self.session = requests.Session()
        self.session.headers["User-Agent"] = self.cfg["user_agent"]
        self._robots: dict[str, urllib.robotparser.RobotFileParser] = {}

    def _allowed(self, url: str) -> bool:
        if not self.cfg.get("respect_robots_txt", True):
            return True
        parsed = urllib.parse.urlparse(url)
        base = f"{parsed.scheme}://{parsed.netloc}"
        if base not in self._robots:
            rp = urllib.robotparser.RobotFileParser()
            rp.set_url(base + "/robots.txt")
            try:
                rp.read()
            except Exception:
                pass
            self._robots[base] = rp
        try:
            return self._robots[base].can_fetch(self.cfg["user_agent"], url)
        except Exception:
            return True

    def fetch(self, url: str) -> requests.Response | None:
        if not self._allowed(url):
            return None
        try:
            r = self.session.get(url, timeout=self.cfg["timeout_seconds"],
                                 allow_redirects=True)
            time.sleep(self.cfg.get("delay_between_requests", 1.0))
            return r
        except requests.RequestException:
            return None

    def discover(self, root: str) -> list[str]:
        """BFS crawl of internal links up to max_pages."""
        seen, queue = {root}, [root]
        pages = []
        netloc = urllib.parse.urlparse(root).netloc
        while queue and len(pages) < self.cfg["max_pages"]:
            url = queue.pop(0)
            resp = self.fetch(url)
            if resp is None or "text/html" not in resp.headers.get("content-type", ""):
                continue
            pages.append((url, resp))
            soup = BeautifulSoup(resp.text, "html.parser")
            for a in soup.find_all("a", href=True):
                nxt = urllib.parse.urljoin(url, a["href"]).split("#")[0].rstrip("/")
                if urllib.parse.urlparse(nxt).netloc == netloc and nxt not in seen:
                    seen.add(nxt)
                    queue.append(nxt)
        return pages


def audit_page(url: str, resp: requests.Response) -> PageAudit:
    a = PageAudit(url=url, status=resp.status_code)
    soup = BeautifulSoup(resp.text, "html.parser")
    check = _checker(a)

    # --- status & speed ---
    check(resp.status_code == 200, f"HTTP {resp.status_code}", "Returns HTTP 200")
    if resp.status_code != 200:
        # A broken link — there's no real page here to check meta tags,
        # canonical, alt text etc. on. One clear message beats a wall of
        # noise about a page that doesn't exist.
        a.issues.append(
            "This is a broken internal link, not a live page — decide "
            "whether to restore the page or remove links pointing to it "
            "(auto-fix only edits pages that actually exist)")
        return a
    ttfb = resp.elapsed.total_seconds()
    a.data["response_time_s"] = round(ttfb, 2)
    if ttfb > 2.5:
        a.issues.append(f"Slow response: {ttfb:.1f}s (target < 1s)")
    elif ttfb > 1.0:
        a.warnings.append(f"Response time {ttfb:.1f}s could be faster")
    else:
        a.passed.append("Fast server response")

    # --- title ---
    title = soup.title.string.strip() if soup.title and soup.title.string else ""
    a.data["title"] = title
    check(bool(title), "Missing <title>", "Title tag present")
    if title:
        if len(title) > 60:
            a.warnings.append(f"Title is {len(title)} chars (Google truncates ~60)")
        elif len(title) < 25:
            a.warnings.append("Title under 25 chars — likely too thin")
        else:
            a.passed.append("Title length optimal")

    # --- meta description ---
    md = soup.find("meta", attrs={"name": "description"})
    desc = md.get("content", "").strip() if md else ""
    a.data["meta_description"] = desc
    check(bool(desc), "Missing meta description", "Meta description present")
    if desc and not (70 <= len(desc) <= 160):
        a.warnings.append(f"Meta description {len(desc)} chars (aim 70–160)")

    # --- headings ---
    h1s = soup.find_all("h1")
    check(len(h1s) == 1,
          f"{len(h1s)} H1 tags found (should be exactly 1)",
          "Exactly one H1")

    # --- canonical ---
    canon = soup.find("link", rel="canonical")
    check(canon is not None, "No canonical URL", "Canonical URL set")

    # --- viewport (mobile) ---
    vp = soup.find("meta", attrs={"name": "viewport"})
    check(vp is not None, "No viewport meta — fails mobile-first indexing",
          "Mobile viewport configured")

    # --- images alt text ---
    imgs = soup.find_all("img")
    missing_alt = [i for i in imgs if not i.get("alt")]
    if imgs:
        check(len(missing_alt) == 0,
              f"{len(missing_alt)}/{len(imgs)} images missing alt text",
              "All images have alt text")

    # --- https ---
    check(url.startswith("https://"), "Not served over HTTPS", "HTTPS enabled")

    # --- open graph (social + AI previews) ---
    og = soup.find("meta", property="og:title")
    check(og is not None, "No Open Graph tags", "Open Graph tags present")

    # --- word count / thin content ---
    text = soup.get_text(" ", strip=True)
    words = len(re.findall(r"\w+", text))
    a.data["word_count"] = words
    if words < 200:
        a.issues.append(f"Thin content: {words} words")
    elif words < 400:
        a.warnings.append(f"Light content: {words} words")
    else:
        a.passed.append(f"Substantial content ({words} words)")

    return a


def _checker(a: PageAudit):
    def check(ok: bool, fail_msg: str, pass_msg: str):
        (a.passed if ok else a.issues).append(pass_msg if ok else fail_msg)
    return check


def check_sitemap(crawler: SiteCrawler, root: str) -> dict:
    url = root.rstrip("/") + "/sitemap.xml"
    r = crawler.fetch(url)
    ok = r is not None and r.status_code == 200 and "<urlset" in (r.text or "")[:2000] or \
         (r is not None and r.status_code == 200 and "<sitemapindex" in (r.text or "")[:2000])
    return {"url": url, "exists": bool(ok),
            "note": "OK" if ok else "Missing or invalid sitemap.xml — search & AI crawlers rely on it"}


def check_robots(crawler: SiteCrawler, root: str) -> dict:
    url = root.rstrip("/") + "/robots.txt"
    r = crawler.fetch(url)
    exists = r is not None and r.status_code == 200
    result = {"url": url, "exists": exists, "ai_bots": {}}
    if exists:
        txt = r.text.lower()
        # Which AI crawlers are addressed (blocked or allowed)?
        for bot in ["gptbot", "chatgpt-user", "oai-searchbot", "claudebot",
                    "claude-web", "perplexitybot", "google-extended", "bingbot",
                    "cohere-ai", "ccbot"]:
            result["ai_bots"][bot] = ("mentioned" if bot in txt else "not mentioned")
        result["note"] = ("Review AI bot rules: blocking GPTBot/ClaudeBot/PerplexityBot "
                          "removes you from AI answers; allowing them earns citations.")
    else:
        result["note"] = "No robots.txt — add one with a Sitemap: line and explicit AI-bot policy"
    return result
