#!/usr/bin/env python3
"""
CSS Extractor - Extract and document CSS from any website.

Usage:
    css-extract --url https://example.com [--output ./path/] [--timeout 15] [--quiet] [--show-errors]
"""

from __future__ import annotations

import argparse
import logging
import re
import sys
import time
import shutil
from collections import Counter
from datetime import datetime
from pathlib import Path
from urllib.parse import urljoin, urlparse

try:
    from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
    from bs4 import BeautifulSoup
    import httpx
    import cssutils
    from rich.console import Console
except ImportError as e:
    print(f"Missing dependency: {e}")
    print("Run: pip install playwright httpx beautifulsoup4 cssutils rich")
    print("Then: playwright install chromium")
    sys.exit(1)

# === CONFIGURATION ===
# Constants here define tool behavior, file classification, and summary thresholds.

CDN_DOMAINS = {
    "cdn.jsdelivr.net",
    "cdnjs.cloudflare.com",
    "unpkg.com",
    "fonts.googleapis.com",
    "fonts.gstatic.com",
    "stackpath.bootstrapcdn.com",
    "maxcdn.bootstrapcdn.com",
    "use.fontawesome.com",
    "kit.fontawesome.com",
    "ajax.googleapis.com",
    "code.jquery.com",
    "cdn.tailwindcss.com",
}

FONT_EXTENSIONS = {".woff", ".woff2", ".ttf", ".otf", ".eot"}
DEFAULT_SUMMARY_LINE_THRESHOLD = 5_000
# Output directory is relative to script location, not current working directory
SCRIPT_DIR = Path(__file__).resolve().parent
DEFAULT_OUTPUT_DIR = SCRIPT_DIR / "CSS-EXTRACT-RESULTS"
REPORT_FILENAME = "REPORT.md"
INLINE_STYLES_FILENAME = "INLINE-STYLES.css"
HTML_BODY_FILENAME = "CLEANED-HTML.html"
DIR_ASSETS = "ASSETS"
DIR_CSS = "CSS"
DIR_FONTS = "FONTS"
DIR_SUMMARIES = "SUMMARIES"

ICON_FONT_KEYWORDS = {
    "icon",
    "icons",
    "iconfont",
    "glyph",
    "fontawesome",
    "font-awesome",
    "material",
    "feather",
    "heroicons",
    "ionicons",
    "octicons",
    "phosphor",
    "remix",
    "bootstrap-icons",
    "lucide",
    "line-rounded",
    "line-square",
    "filled-icon",
    "social-icon",
    "webflow-icons",
}

TYPOGRAPHY_KEYWORDS = {
    "regular",
    "medium",
    "semibold",
    "bold",
    "black",
    "thin",
    "light",
    "italic",
    "book",
    "display",
    "sans",
    "serif",
    "mono",
    "variable",
}

console = Console()


class CSSParseLogger(logging.Handler):
    """Collect cssutils parsing errors into a simple list for reporting."""
    def __init__(self):
        super().__init__()
        self.messages: list[str] = []

    def emit(self, record):
        self.messages.append(record.getMessage())


_css_parse_logger = CSSParseLogger()
# cssutils uses a custom errorhandler, so we route its output into a standard logger
# and capture messages via CSSParseLogger for reporting in REPORT.md when requested.
_css_logger = logging.getLogger("cssutils")
_css_logger.setLevel(logging.ERROR)
_css_logger.handlers = []
_css_logger.addHandler(_css_parse_logger)
_css_logger.propagate = False
cssutils.log.setLog(_css_logger)
# Disable cssutils validation because many modern CSS features would otherwise
# be flagged as errors, but we still want to download and summarize the CSS.
CSSUTILS_PARSER = cssutils.CSSParser(validate=False)


# === DATA CLASSES ===
# These store extraction artifacts and metadata used by the report generator.

class CSSFile:
    def __init__(
        self,
        url: str,
        content: str,
        source_type: str,
        filename: str | None = None,
        base_url: str | None = None,
    ):
        self.url = url
        self.base_url = base_url or url
        self.content = content
        self.source_type = source_type  # 'inline', 'external', 'vendor'
        self.filename = filename or self._generate_filename()
        self.line_count = len(content.splitlines())
        self.parse_errors: list[str] = []
        self.parsed_sheet = None
        self.summary_path: Path | None = None

    def _generate_filename(self):
        if self.source_type == "inline":
            return INLINE_STYLES_FILENAME
        parsed = urlparse(self.url)
        name = Path(parsed.path).name or "styles.css"
        if not name.endswith(".css"):
            name += ".css"
        return name


class FontFile:
    def __init__(self, url: str, content: bytes, filename: str, family: str | None = None):
        self.url = url
        self.content = content
        self.filename = filename
        self.format = Path(filename).suffix.lstrip(".")
        self.family = family
        self.category: str | None = None


# === CORE EXTRACTION HELPERS ===
# Lightweight helpers for URL classification, CSS parsing, and font handling.

def classify_url(url: str, base_domain: str) -> str:
    """Classify a URL as external or vendor/CDN."""
    parsed = urlparse(url)
    domain = parsed.netloc.lower()

    if domain in CDN_DOMAINS:
        return "vendor"
    if domain != base_domain and domain:
        return "vendor"
    return "external"


def parse_css_with_errors(css_content: str):
    """Parse CSS content with cssutils and collect parse warnings/errors."""
    _css_parse_logger.messages = []
    try:
        sheet = CSSUTILS_PARSER.parseString(css_content)
    except Exception as e:
        return None, [str(e)]
    return sheet, list(_css_parse_logger.messages)


def extract_urls_from_css_value(value: str, base_url: str) -> list[str]:
    """Extract URLs from a CSS property value."""
    if not value:
        return []
    url_pattern = r'url\(["\']?([^"\')\s]+)["\']?\)'
    matches = re.findall(url_pattern, value)
    urls = []
    for raw_url in matches:
        absolute_url = urljoin(base_url, raw_url)
        urls.append(absolute_url)
    return urls


def extract_font_face_metadata(parsed_sheet, base_url: str) -> list[dict[str, str | None]]:
    """Extract font metadata (family, src URL) from parsed CSS."""
    items: list[dict[str, str | None]] = []
    if not parsed_sheet:
        return items
    for rule in parsed_sheet:
        if getattr(rule, "type", None) == getattr(rule, "FONT_FACE_RULE", None):
            family = rule.style.getPropertyValue("font-family") or None
            src_value = rule.style.getPropertyValue("src")
            for url in extract_urls_from_css_value(src_value, base_url):
                items.append({"url": url, "family": family})
    return items


def classify_font(url: str, filename: str, family: str | None) -> str:
    """Classify a font as Icon, Typography, or Other based on name hints."""
    text = " ".join(part for part in [url, filename, family] if part).lower()
    if any(keyword in text for keyword in ICON_FONT_KEYWORDS):
        return "Icon"
    if "fonts.googleapis.com" in text or "fonts.gstatic.com" in text:
        return "Typography"
    if any(keyword in text for keyword in TYPOGRAPHY_KEYWORDS):
        return "Typography"
    return "Other"


# === SUMMARY HELPERS ===
# The functions below generate the large-file summaries designed for AI context.

def _top_items(counter: Counter, limit: int = 20) -> list[tuple[str, int]]:
    """Return most common items while preserving count ordering."""
    return counter.most_common(limit)


def _normalize_value(value: str) -> str:
    """Collapse whitespace and normalize CSS values for stable counting."""
    return re.sub(r"\s+", " ", value.strip())


def _classify_token(token_name: str, value: str | None = None) -> str:
    """Infer token category using both the token name and its value."""
    name = token_name.lower()
    if "line-height" in name:
        return "Typography"
    if "shadow" in name:
        return "Shadows"
    if any(k in name for k in ["radius", "radii", "round"]):
        return "Radii"
    value_type = _token_value_type(value or "")
    if value_type:
        return value_type
    if any(k in name for k in ["color", "colour", "bg", "background", "text", "border", "primary", "accent", "brand"]):
        return "Colors"
    if any(k in name for k in ["space", "spacing", "size", "sizes", "gap", "padding", "margin", "inset"]):
        return "Spacing"
    if any(k in name for k in ["font", "type", "typography", "weight", "line", "leading", "tracking", "letter"]):
        return "Typography"
    return "Other"


def _token_value_type(value: str) -> str | None:
    """Infer token category directly from a value when possible."""
    if re.search(r"#[0-9a-fA-F]{3,8}\b", value):
        return "Colors"
    if re.search(r"\brgba?\([^)]+\)|\bhsla?\([^)]+\)", value, flags=re.IGNORECASE):
        return "Colors"
    if re.search(r"\b(?:\d+(\.\d+)?)(px|rem|em|%|vh|vw)\b", value):
        return "Spacing"
    if "shadow" in value:
        return "Shadows"
    if "var(--sizes" in value or "var(--spacing" in value:
        return "Spacing"
    if "var(--typography" in value or "var(--line-height" in value:
        return "Typography"
    return None


def _token_prefix(token_name: str) -> str:
    """Extract the prefix used for token families (e.g., --sizes--s6 -> sizes)."""
    name = token_name.lstrip("-")
    if "--" in name:
        return name.split("--", 1)[0]
    return name


def _should_skip_literal_value(value: str) -> bool:
    """Exclude generic values that do not help summarize layout patterns."""
    value = value.strip().lower()
    if not value:
        return True
    if "var(" in value:
        return True
    if value in {"auto", "inherit", "initial", "unset", "none", "normal"}:
        return True
    if re.fullmatch(r"(0|0px|0%|0rem|0em)(\s+(0|0px|0%|0rem|0em))*", value):
        return True
    return False


def _collect_property_values(css_content: str, properties: list[str]) -> Counter:
    """Aggregate literal property values (e.g., padding, margin, shadows)."""
    values = Counter()
    for prop in properties:
        pattern = rf"{re.escape(prop)}\s*:\s*([^;}}{{]+)"
        for match in re.findall(pattern, css_content, flags=re.IGNORECASE):
            value = _normalize_value(match)
            if _should_skip_literal_value(value):
                continue
            values[value] += 1
    return values


def _should_ignore_typography_selector(selector: str) -> bool:
    """Filter out selector types that typically add noise to text styles."""
    selector = selector.lower()
    if "::placeholder" in selector:
        return True
    if any(x in selector for x in [".input", "input", "textarea", "select"]):
        return True
    return False


def _extract_font_faces(parsed_sheet, css_content: str) -> dict[str, dict[str, set[str]]]:
    """Return font families with weight/style info from @font-face rules."""
    families: dict[str, dict[str, set[str]]] = {}

    if parsed_sheet:
        for rule in parsed_sheet:
            if getattr(rule, "type", None) == getattr(rule, "FONT_FACE_RULE", None):
                family = rule.style.getPropertyValue("font-family") or "Unknown"
                family = family.strip().strip('"').strip("'")
                weight = (rule.style.getPropertyValue("font-weight") or "normal").strip()
                style = (rule.style.getPropertyValue("font-style") or "normal").strip()
                if family not in families:
                    families[family] = {"weights": set(), "styles": set()}
                families[family]["weights"].add(weight)
                families[family]["styles"].add(style)
        return families

    for block in re.findall(r"@font-face\s*\{([^}]+)\}", css_content, flags=re.IGNORECASE):
        family_match = re.search(r"font-family\s*:\s*([^;}{]+)", block, flags=re.IGNORECASE)
        if not family_match:
            continue
        family = family_match.group(1).strip().strip('"').strip("'")
        weight = (re.search(r"font-weight\s*:\s*([^;}{]+)", block, flags=re.IGNORECASE) or [None, "normal"])[1]
        style = (re.search(r"font-style\s*:\s*([^;}{]+)", block, flags=re.IGNORECASE) or [None, "normal"])[1]
        if family not in families:
            families[family] = {"weights": set(), "styles": set()}
        families[family]["weights"].add(_normalize_value(weight))
        families[family]["styles"].add(_normalize_value(style))

    return families


def _extract_typography_styles(css_content: str) -> list[dict[str, str]]:
    """Extract representative heading/text styles and de-duplicate by signature."""
    keyword_pattern = r"(heading|title|display|text|body|subtitle|eyebrow|label|caption|hero|lead)"
    style_groups: dict[tuple, dict[str, list[str] | int | dict[str, str]]] = {}

    for selector_block, decl_block in re.findall(r"([^{]+)\{([^}]+)\}", css_content):
        selector_block = selector_block.strip()
        if not selector_block or selector_block.startswith("@"):
            continue
        if _should_ignore_typography_selector(selector_block):
            continue
        if not (
            re.search(r"\bh[1-6]\b", selector_block)
            or re.search(rf"\.{keyword_pattern}[A-Za-z0-9_-]*", selector_block, flags=re.IGNORECASE)
        ):
            continue

        def prop(name: str) -> str | None:
            match = re.search(rf"{name}\s*:\s*([^;}}{{]+)", decl_block, flags=re.IGNORECASE)
            return _normalize_value(match.group(1)) if match else None

        props = {
            "font-family": prop("font-family"),
            "font-size": prop("font-size"),
            "font-weight": prop("font-weight"),
            "line-height": prop("line-height"),
            "letter-spacing": prop("letter-spacing"),
            "text-transform": prop("text-transform"),
        }
        if not any(props.values()):
            continue

        signature = tuple(props.get(k) for k in ["font-family", "font-size", "font-weight", "line-height", "letter-spacing", "text-transform"])
        if signature not in style_groups:
            style_groups[signature] = {"props": props, "selectors": [], "count": 0}
        style_groups[signature]["count"] += 1
        if len(style_groups[signature]["selectors"]) < 3:
            style_groups[signature]["selectors"].append(_normalize_value(selector_block))

    styles = sorted(
        style_groups.values(),
        key=lambda item: item["count"],
        reverse=True,
    )
    return styles[:12]


def _extract_text_font_usage(css_content: str) -> Counter:
    """Count font-family usage in heading/text selectors to identify primary fonts."""
    usage = Counter()
    keyword_pattern = r"(heading|title|display|text|body|subtitle|eyebrow|label|caption|hero|lead)"

    for selector_block, decl_block in re.findall(r"([^{]+)\{([^}]+)\}", css_content):
        selector_block = selector_block.strip()
        if not selector_block or selector_block.startswith("@"):
            continue
        if _should_ignore_typography_selector(selector_block):
            continue
        if not (
            re.search(r"\bh[1-6]\b", selector_block)
            or re.search(rf"\.{keyword_pattern}[A-Za-z0-9_-]*", selector_block, flags=re.IGNORECASE)
        ):
            continue

        match = re.search(r"font-family\s*:\s*([^;}{]+)", decl_block, flags=re.IGNORECASE)
        if not match:
            continue
        value = match.group(1)
        for family in value.split(","):
            family = family.strip().strip('"').strip("'")
            if family and family.lower() not in {"inherit", "unset"}:
                usage[family] += 1

    return usage


def summarize_css_content(
    css_content: str,
    css_url: str,
    line_count: int,
    filename: str,
    parsed_sheet=None,
) -> str:
    """Generate a structured summary for large CSS files."""
    token_defs: dict[str, str] = {}
    for name, value in re.findall(r"(--[A-Za-z0-9_-]+)\s*:\s*([^;}{]+)", css_content):
        token_defs[name] = _normalize_value(value)

    token_usage = Counter()
    for name in re.findall(r"var\(\s*(--[A-Za-z0-9_-]+)", css_content):
        token_usage[name] += 1

    tokens_by_category: dict[str, list[tuple[str, str, int]]] = {
        "Colors": [],
        "Spacing": [],
        "Radii": [],
        "Shadows": [],
        "Typography": [],
        "Other": [],
    }
    for token, value in token_defs.items():
        count = token_usage.get(token, 0)
        if count == 0:
            continue
        category = _classify_token(token, value)
        tokens_by_category.setdefault(category, [])
        tokens_by_category[category].append((token, value, count))

    for category in tokens_by_category:
        tokens_by_category[category].sort(key=lambda item: (-item[2], item[0]))
        tokens_by_category[category] = tokens_by_category[category][:25]

    font_faces = _extract_font_faces(parsed_sheet, css_content)
    text_font_usage = _extract_text_font_usage(css_content)
    typography_fonts = []
    icon_fonts = []
    other_fonts = []

    for family, meta in font_faces.items():
        label = classify_font("", "", family)
        entry = {
            "family": family,
            "weights": ", ".join(sorted(meta["weights"])),
            "styles": ", ".join(sorted(meta["styles"])),
        }
        if label == "Icon":
            icon_fonts.append(entry)
        elif label == "Typography":
            typography_fonts.append(entry)
        else:
            other_fonts.append(entry)

    primary_fonts = _top_items(text_font_usage, 5)

    typography_styles = _extract_typography_styles(css_content)

    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    summary = f"""# CSS Summary: {filename}

Source: {css_url}
Lines: {line_count:,}
Generated: {timestamp}

This summary focuses on design tokens and typography structure. It is heuristic and may omit or simplify parts of the CSS.

## Design Tokens
"""

    tokens_by_prefix: dict[str, list[tuple[str, str, int]]] = {}
    for category, items in tokens_by_category.items():
        for token, value, count in items:
            prefix = _token_prefix(token)
            tokens_by_prefix.setdefault(prefix, [])
            tokens_by_prefix[prefix].append((token, value, count))

    prefix_usage = []
    for prefix, items in tokens_by_prefix.items():
        usage = sum(count for _, _, count in items)
        if usage > 0:
            prefix_usage.append((prefix, usage))
    prefix_usage.sort(key=lambda item: item[1], reverse=True)

    summary += "\n### Token Families (Top 20 by usage)\n"
    if prefix_usage:
        for prefix, usage in prefix_usage[:20]:
            summary += f"- {prefix} ({usage}x)\n"
    else:
        summary += "- None detected\n"

    # Literal layout values (non-token sizes) help reveal real spacing patterns.
    margin_values = _collect_property_values(
        css_content,
        ["margin", "margin-top", "margin-right", "margin-bottom", "margin-left"],
    )
    padding_values = _collect_property_values(
        css_content,
        ["padding", "padding-top", "padding-right", "padding-bottom", "padding-left"],
    )
    gap_values = _collect_property_values(
        css_content,
        ["gap", "row-gap", "column-gap"],
    )
    radius_values = _collect_property_values(css_content, ["border-radius"])
    shadow_values = _collect_property_values(css_content, ["box-shadow", "text-shadow"])
    breakpoint_values = Counter()
    for value in re.findall(r"@media\s*([^{]+)\{", css_content, flags=re.IGNORECASE):
        breakpoint_values[_normalize_value(value)] += 1

    # Motion-related properties are summarized separately because they are
    # both important and easy to miss in large files.
    transition_values = _collect_property_values(css_content, ["transition"])
    transition_duration_values = _collect_property_values(css_content, ["transition-duration"])
    transition_timing_values = _collect_property_values(css_content, ["transition-timing-function"])
    animation_values = _collect_property_values(css_content, ["animation"])
    animation_name_values = _collect_property_values(css_content, ["animation-name"])
    animation_duration_values = _collect_property_values(css_content, ["animation-duration"])
    animation_timing_values = _collect_property_values(css_content, ["animation-timing-function"])
    keyframes_values = Counter()
    for value in re.findall(r"@keyframes\s+([A-Za-z0-9_-]+)", css_content, flags=re.IGNORECASE):
        keyframes_values[_normalize_value(value)] += 1

    for category in ["Colors", "Spacing", "Radii", "Shadows", "Typography", "Other"]:
        summary += f"\n### {category}\n"
        items = tokens_by_category.get(category, [])
        if items:
            for token, value, count in items:
                summary += f"- {token}: {value} (used {count}x)\n"
        else:
            summary += "- None detected\n"

    summary += "\n## Layout & Spacing (Literal Values)\n"

    summary += "\n### Margin Values (Top 12)\n"
    if margin_values:
        for value, count in _top_items(margin_values, 12):
            summary += f"- {value} ({count})\n"
    else:
        summary += "- None detected\n"

    summary += "\n### Padding Values (Top 12)\n"
    if padding_values:
        for value, count in _top_items(padding_values, 12):
            summary += f"- {value} ({count})\n"
    else:
        summary += "- None detected\n"

    summary += "\n### Gap Values (Top 10)\n"
    if gap_values:
        for value, count in _top_items(gap_values, 10):
            summary += f"- {value} ({count})\n"
    else:
        summary += "- None detected\n"

    summary += "\n### Border Radius (Top 10)\n"
    if radius_values:
        for value, count in _top_items(radius_values, 10):
            summary += f"- {value} ({count})\n"
    else:
        summary += "- None detected\n"

    summary += "\n### Shadows (Top 10)\n"
    if shadow_values:
        for value, count in _top_items(shadow_values, 10):
            summary += f"- {value} ({count})\n"
    else:
        summary += "- None detected\n"

    summary += "\n### Breakpoints\n"
    if breakpoint_values:
        for value, count in _top_items(breakpoint_values, 12):
            summary += f"- {value} ({count})\n"
    else:
        summary += "- None detected\n"

    summary += "\n## Motion & Transitions\n\n"
    summary += "### Transitions (Top 10)\n"
    if transition_values:
        for value, count in _top_items(transition_values, 10):
            summary += f"- {value} ({count})\n"
    else:
        summary += "- None detected\n"

    summary += "\n### Transition Durations (Top 8)\n"
    if transition_duration_values:
        for value, count in _top_items(transition_duration_values, 8):
            summary += f"- {value} ({count})\n"
    else:
        summary += "- None detected\n"

    summary += "\n### Transition Timing Functions (Top 8)\n"
    if transition_timing_values:
        for value, count in _top_items(transition_timing_values, 8):
            summary += f"- {value} ({count})\n"
    else:
        summary += "- None detected\n"

    summary += "\n### Animations (Top 10)\n"
    if animation_values:
        for value, count in _top_items(animation_values, 10):
            summary += f"- {value} ({count})\n"
    else:
        summary += "- None detected\n"

    summary += "\n### Animation Names (Top 10)\n"
    if animation_name_values:
        for value, count in _top_items(animation_name_values, 10):
            summary += f"- {value} ({count})\n"
    else:
        summary += "- None detected\n"

    summary += "\n### Animation Durations (Top 8)\n"
    if animation_duration_values:
        for value, count in _top_items(animation_duration_values, 8):
            summary += f"- {value} ({count})\n"
    else:
        summary += "- None detected\n"

    summary += "\n### Animation Timing Functions (Top 8)\n"
    if animation_timing_values:
        for value, count in _top_items(animation_timing_values, 8):
            summary += f"- {value} ({count})\n"
    else:
        summary += "- None detected\n"

    summary += "\n### Keyframes\n"
    if keyframes_values:
        for value, count in _top_items(keyframes_values, 12):
            summary += f"- {value} ({count})\n"
    else:
        summary += "- None detected\n"

    summary += "\n## Typography Assets\n\n"
    summary += "### Primary Text Fonts (Usage in Headings/Text)\n"
    if primary_fonts:
        for family, count in primary_fonts:
            summary += f"- {family} ({count}x)\n"
    else:
        summary += "- None detected\n"

    summary += "\n"
    summary += "### Typography Fonts\n"
    if typography_fonts:
        for entry in typography_fonts:
            summary += f"- {entry['family']} (weights: {entry['weights']}; styles: {entry['styles']})\n"
    else:
        summary += "- None detected\n"

    summary += "\n### Icon Fonts\n"
    if icon_fonts:
        for entry in icon_fonts:
            summary += f"- {entry['family']} (weights: {entry['weights']}; styles: {entry['styles']})\n"
    else:
        summary += "- None detected\n"

    summary += "\n### Other Fonts\n"
    if other_fonts:
        for entry in other_fonts:
            summary += f"- {entry['family']} (weights: {entry['weights']}; styles: {entry['styles']})\n"
    else:
        summary += "- None detected\n"

    summary += "\n## Typography Styles (Headings & Text Classes)\n"
    if typography_styles:
        for style in typography_styles:
            props = style["props"]
            selectors = ", ".join(style["selectors"])
            summary += f"- Selectors: {selectors}\n"
            for key, label in [
                ("font-family", "font-family"),
                ("font-size", "font-size"),
                ("font-weight", "font-weight"),
                ("line-height", "line-height"),
                ("letter-spacing", "letter-spacing"),
                ("text-transform", "text-transform"),
            ]:
                if props.get(key):
                    summary += f"  {label}: {props[key]}\n"
            summary += "\n"
    else:
        summary += "- None detected\n"

    return summary


def extract_imports(css_content: str, base_url: str, parsed_sheet=None) -> list[str]:
    """Extract @import URLs from CSS content."""
    urls = []
    if parsed_sheet:
        for rule in parsed_sheet:
            if getattr(rule, "type", None) == getattr(rule, "IMPORT_RULE", None):
                href = getattr(rule, "href", None)
                if href:
                    urls.append(urljoin(base_url, href))

    import_pattern = r'@import\s+(?:url\(["\']?([^"\')\s]+)["\']?\)|["\']([^"\']+)["\'])'
    matches = re.findall(import_pattern, css_content)
    for match in matches:
        raw_url = match[0] or match[1]
        if raw_url:
            absolute_url = urljoin(base_url, raw_url)
            if absolute_url not in urls:
                urls.append(absolute_url)
    return urls


def extract_font_urls(css_content: str, base_url: str, parsed_sheet=None) -> list[str]:
    """Extract font URLs from @font-face declarations."""
    font_urls = []
    if parsed_sheet:
        for rule in parsed_sheet:
            if getattr(rule, "type", None) == getattr(rule, "FONT_FACE_RULE", None):
                src_value = rule.style.getPropertyValue("src")
                for absolute_url in extract_urls_from_css_value(src_value, base_url):
                    parsed = urlparse(absolute_url)
                    ext = Path(parsed.path).suffix.lower()
                    if ext in FONT_EXTENSIONS:
                        font_urls.append(absolute_url)

    if not font_urls:
        url_pattern = r'url\(["\']?([^"\')\s]+)["\']?\)'
        matches = re.findall(url_pattern, css_content)
        for raw_url in matches:
            parsed = urlparse(raw_url)
            ext = Path(parsed.path).suffix.lower()
            if ext in FONT_EXTENSIONS:
                absolute_url = urljoin(base_url, raw_url)
                font_urls.append(absolute_url)
    return font_urls


def fetch_with_retry(
    client: httpx.Client,
    url: str,
    max_retries: int = 3,
    binary: bool = False,
    log=None,
    error_log=None,
    errors: list[str] | None = None,
):
    """Fetch URL with exponential backoff retry."""
    for attempt in range(max_retries):
        try:
            response = client.get(url, follow_redirects=True, timeout=30.0)
            response.raise_for_status()
            return response.content if binary else response.text
        except Exception as e:
            if attempt < max_retries - 1:
                wait_time = 2 ** attempt
                if log:
                    log(f"  Retry {attempt + 1}/{max_retries} for {url} in {wait_time}s...", "yellow")
                time.sleep(wait_time)
            else:
                message = f"Failed to fetch {url}: {e}"
                if error_log:
                    error_log(f"  {message}", "red")
                elif log:
                    log(f"  {message}", "red")
                if errors is not None:
                    errors.append(message)
                return None
    return None


# === MAIN EXTRACTOR ===
# The CSSExtractor class orchestrates the end-to-end pipeline.

class CSSExtractor:
    def __init__(
        self,
        url: str,
        output_dir: str,
        timeout: int,
        quiet: bool,
        show_errors: bool,
        summary_line_threshold: int,
    ):
        self.url = url
        self.parsed_url = urlparse(url)
        self.base_domain = self.parsed_url.netloc
        self.output_dir = Path(output_dir) / self.base_domain.upper()
        self.timeout = timeout * 1000  # Convert to ms
        self.quiet = quiet
        self.show_errors = show_errors
        self.summary_line_threshold = summary_line_threshold

        self.css_files: list[CSSFile] = []
        self.font_files: list[FontFile] = []
        self.inline_styles: list[dict[str, str | int]] = []
        self.html_content: str | None = None
        self.errors: list[str] = []
        self.imported_urls: set[str] = set()
        self.imported_urls_downloaded: set[str] = set()
        self.external_css_urls: list[tuple[str, str]] = []

    def log(self, message: str, style: str = None):
        if not self.quiet:
            if style:
                console.print(f"[{style}]{message}[/{style}]")
            else:
                console.print(message)

    def log_error(self, message: str, style: str = None):
        if style:
            console.print(f"[{style}]{message}[/{style}]")
        else:
            console.print(message)

    def run(self):
        """Main extraction process."""
        self.log(f"\n[bold blue]CSS Extractor[/bold blue]", "")
        self.log(f"Target: {self.url}")
        self.log(f"Output: {self.output_dir}\n")

        # Step 1: Render page with Playwright (captures dynamic CSS inserted by JS).
        html_content = self._render_page()
        if not html_content:
            console.print("[red]Failed to load page. Aborting.[/red]")
            return False
        self.html_content = html_content

        # Step 2: Parse HTML and extract CSS references.
        self._extract_css_references(html_content)

        # Step 3: Download CSS files and resolve @imports recursively.
        self._download_css_files()

        # Step 4: Extract and download fonts referenced by @font-face.
        self._download_fonts()

        # Step 5: Save all files, and generate summaries for large CSS files.
        self._save_files()

        # Step 6: Generate overview report.
        self._generate_overview()

        self.log(f"\n[bold green]✓ Extraction complete![/bold green]")
        self.log(f"  CSS files: {len(self.css_files)}")
        self.log(f"  Font files: {len(self.font_files)}")
        self.log(f"  Output: {self.output_dir}")

        return True

    def _render_page(self) -> str | None:
        """Render page with headless browser."""
        self.log("[cyan]→ Launching browser...[/cyan]")

        try:
            with sync_playwright() as p:
                browser = p.chromium.launch(headless=True)
                context = browser.new_context(
                    viewport={"width": 1920, "height": 1080},
                    user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
                )
                page = context.new_page()

                self.log(f"[cyan]→ Loading {self.url}...[/cyan]")

                try:
                    page.goto(self.url, wait_until="domcontentloaded", timeout=self.timeout)
                except PlaywrightTimeout:
                    timeout_seconds = self.timeout // 1000
                    self.errors.append(f"Navigation timeout after {timeout_seconds}s for {self.url}")
                    self.log("  Navigation timeout, continuing with current state...", "yellow")

                # Additional wait for JS rendering beyond DOM ready.
                self.log(f"[cyan]→ Waiting for JS render ({self.timeout // 1000}s max)...[/cyan]")
                try:
                    page.wait_for_load_state("networkidle", timeout=self.timeout)
                except PlaywrightTimeout:
                    timeout_seconds = self.timeout // 1000
                    self.errors.append(f"JS render timeout after {timeout_seconds}s for {self.url}")
                    self.log("  JS render timeout, continuing with current state...", "yellow")

                html = page.content()
                browser.close()

                self.log("[green]  ✓ Page rendered successfully[/green]")
                return html

        except Exception as e:
            self.errors.append(f"Browser error: {e}")
            console.print(f"[red]Browser error: {e}[/red]")
            return None

    def _extract_css_references(self, html: str):
        """Parse HTML and find all CSS sources."""
        self.log("[cyan]→ Extracting CSS references...[/cyan]")

        soup = BeautifulSoup(html, "html.parser")

        # Extract inline <style> tags and track location for the combined file.
        style_tags = soup.find_all("style")
        for i, tag in enumerate(style_tags, 1):
            content = tag.get_text()
            if content and content.strip():
                self.inline_styles.append({
                    "index": i,
                    "content": content.strip(),
                    "location": "head" if tag.find_parent("head") else "body"
                })

        if self.inline_styles:
            self.log(f"  Found {len(self.inline_styles)} inline <style> tags")

        # Extract <link rel="stylesheet"> tags and classify by domain.
        link_tags = soup.find_all("link", rel="stylesheet")
        external_urls = []
        for tag in link_tags:
            href = tag.get("href")
            if href:
                absolute_url = urljoin(self.url, href)
                source_type = classify_url(absolute_url, self.base_domain)
                external_urls.append((absolute_url, source_type))

        self.external_css_urls = external_urls
        self.log(f"  Found {len(external_urls)} external stylesheets")

    def _download_css_files(self):
        """Download all external CSS files and resolve @imports."""
        self.log("[cyan]→ Downloading CSS files...[/cyan]")

        downloaded_urls = set()
        urls_to_process = [(url, source_type, False) for url, source_type in self.external_css_urls]

        # Process inline styles first so their @imports are discovered early.
        if self.inline_styles:
            combined_content = self._combine_inline_styles()
            inline_file = CSSFile(
                url="(embedded in HTML)",
                content=combined_content,
                source_type="inline",
                filename=INLINE_STYLES_FILENAME,
                base_url=self.url,
            )
            sheet, parse_errors = parse_css_with_errors(combined_content)
            inline_file.parsed_sheet = sheet
            inline_file.parse_errors = parse_errors
            self._record_parse_errors(inline_file)
            self.css_files.append(inline_file)
            self.log(f"  [green]✓[/green] {INLINE_STYLES_FILENAME} ({inline_file.line_count} lines)")

            inline_imports = extract_imports(combined_content, self.url, sheet)
            for import_url in inline_imports:
                if import_url not in downloaded_urls:
                    import_type = classify_url(import_url, self.base_domain)
                    urls_to_process.append((import_url, import_type, True))
                    self.imported_urls.add(import_url)
                    self.log(f"    → Found @import in inline: {import_url}", "blue")

        # Use a shared HTTP client for connection reuse and retries.
        with httpx.Client() as client:
            while urls_to_process:
                url, source_type, from_import = urls_to_process.pop(0)

                if url in downloaded_urls:
                    continue
                downloaded_urls.add(url)

                self.log(f"  Fetching: {url[:80]}...")
                content = fetch_with_retry(
                    client,
                    url,
                    log=self.log,
                    error_log=self.log_error,
                    errors=self.errors,
                )

                if content:
                    css_file = CSSFile(url, content, source_type, base_url=url)
                    sheet, parse_errors = parse_css_with_errors(content)
                    css_file.parsed_sheet = sheet
                    css_file.parse_errors = parse_errors
                    self._record_parse_errors(css_file)
                    self.css_files.append(css_file)
                    self.log(f"    [green]✓[/green] {css_file.filename} ({css_file.line_count} lines)")
                    if from_import:
                        self.imported_urls_downloaded.add(url)

                    # Check for @imports in the downloaded file.
                    imports = extract_imports(content, url, sheet)
                    for import_url in imports:
                        if import_url not in downloaded_urls:
                            import_type = classify_url(import_url, self.base_domain)
                            urls_to_process.append((import_url, import_type, True))
                            self.imported_urls.add(import_url)
                            self.log(f"    → Found @import: {import_url}", "blue")

        if self.imported_urls:
            self.imported_urls_downloaded.update(self.imported_urls.intersection(downloaded_urls))

    def _combine_inline_styles(self) -> str:
        """Combine all inline styles with header."""
        header = f"""/*
 * INLINE PAGE STYLES
 * ===================
 * Combined from {len(self.inline_styles)} <style> tag(s) found in the HTML.
 * These styles were embedded directly in the page, not external files.
 *
 * Source: {self.url}
 * Extracted: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
 */

"""
        sections = []
        for style in self.inline_styles:
            section_header = f"/* --- <style> tag #{style['index']} (in <{style['location']}>) --- */"
            sections.append(f"{section_header}\n{style['content']}")

        return header + "\n\n".join(sections)

    def _record_parse_errors(self, css_file: CSSFile):
        """Add a single summarized parse warning per file to the error list."""
        if not css_file.parse_errors:
            return
        if len(css_file.parse_errors) == 1:
            summary = css_file.parse_errors[0]
        else:
            summary = f"{css_file.parse_errors[0]} (+{len(css_file.parse_errors) - 1} more)"
        self.errors.append(f"CSS parse warning (cssutils) in {css_file.url}: {summary}")

    def _extract_body_html(self, html: str) -> str:
        """Return body-only HTML, stripped of scripts/iframes/styles and prettified."""
        soup = BeautifulSoup(html, "html.parser")
        body = soup.body
        if body is None:
            return "<!-- No <body> tag found in rendered HTML. -->\n"

        for tag in body.find_all(["script", "noscript", "iframe", "style", "link"]):
            tag.decompose()

        note = (
            "<!--\n"
            "  NOTE: This HTML is body-only and was cropped for CSS analysis.\n"
            "  Scripts, iframes, and inline styles were removed. Content is prettified.\n"
            "-->\n"
        )
        return note + body.prettify(formatter="html")

    def _download_fonts(self):
        """Extract and download fonts from CSS."""
        self.log("[cyan]→ Extracting fonts...[/cyan]")

        downloaded_urls = set()
        font_urls = []
        font_metadata: dict[str, dict[str, str | None]] = {}

        for css_file in self.css_files:
            metadata_items = extract_font_face_metadata(css_file.parsed_sheet, css_file.base_url)
            for item in metadata_items:
                url = item.get("url")
                if url:
                    font_metadata[url] = {"family": item.get("family")}

            urls = extract_font_urls(css_file.content, css_file.base_url, css_file.parsed_sheet)
            for url in urls:
                if url not in downloaded_urls:
                    font_urls.append(url)
                    downloaded_urls.add(url)

        if not font_urls:
            self.log("  No fonts found")
            return

        self.log(f"  Found {len(font_urls)} font files")

        with httpx.Client() as client:
            for url in font_urls:
                self.log(f"  Fetching: {url[:60]}...")
                content = fetch_with_retry(
                    client,
                    url,
                    binary=True,
                    log=self.log,
                    error_log=self.log_error,
                    errors=self.errors,
                )

                if content:
                    parsed = urlparse(url)
                    filename = Path(parsed.path).name
                    # Clean up filename
                    filename = re.sub(r'[?#].*', '', filename)
                    if not any(filename.endswith(ext) for ext in FONT_EXTENSIONS):
                        filename += ".woff2"

                    family = font_metadata.get(url, {}).get("family")
                    font = FontFile(url, content, filename, family=family)
                    font.category = classify_font(url, filename, family)
                    self.font_files.append(font)
                    self.log(f"    [green]✓[/green] {filename}")
                else:
                    self.errors.append(f"Failed to download font: {url}")

    def _save_files(self):
        """Save all extracted files to disk."""
        self.log("[cyan]→ Saving files...[/cyan]")

        # Create output directories.
        if self.output_dir.exists():
            shutil.rmtree(self.output_dir)

        assets_dir = self.output_dir / DIR_ASSETS
        css_dir = assets_dir / DIR_CSS
        fonts_dir = assets_dir / DIR_FONTS
        self.output_dir.mkdir(parents=True, exist_ok=True)
        assets_dir.mkdir(exist_ok=True)
        css_dir.mkdir(exist_ok=True)
        fonts_dir.mkdir(exist_ok=True)
        summaries_dir = css_dir / DIR_SUMMARIES

        # Save cropped HTML body for CSS analysis.
        if self.html_content:
            body_html = self._extract_body_html(self.html_content)
            body_path = self.output_dir / HTML_BODY_FILENAME
            body_path.write_text(body_html, encoding="utf-8")
            self.log(f"  Saved: {HTML_BODY_FILENAME}")

        # Save CSS files into ASSETS/CSS/ (vendor files get a THIRD-PARTY__ prefix).
        for css_file in self.css_files:
            if css_file.source_type == "vendor":
                filename = css_file.filename
                if not filename.startswith("THIRD-PARTY__"):
                    filename = f"THIRD-PARTY__{filename}"
            else:
                filename = css_file.filename
            filepath = css_dir / filename

            filepath.write_text(css_file.content, encoding="utf-8")
            css_file.filename = filepath.name
            css_file.local_path = filepath.relative_to(self.output_dir)
            self.log(f"  Saved: {css_file.local_path}")

            # Generate a summary for large CSS files to keep AI context manageable.
            if css_file.line_count >= self.summary_line_threshold:
                summaries_dir.mkdir(exist_ok=True)
                summary_filename = f"{css_file.filename}.summary.md"
                summary_path = summaries_dir / summary_filename
                summary_content = summarize_css_content(
                    css_file.content,
                    css_file.url,
                    css_file.line_count,
                    css_file.filename,
                    parsed_sheet=css_file.parsed_sheet,
                )
                summary_path.write_text(summary_content, encoding="utf-8")
                css_file.summary_path = summary_path.relative_to(self.output_dir)
                self.log(f"  Saved: {css_file.summary_path}")

        # Save font files into fonts/.
        for font in self.font_files:
            filepath = fonts_dir / font.filename

            filepath.write_bytes(font.content)
            font.filename = filepath.name
            font.local_path = filepath.relative_to(self.output_dir)
            self.log(f"  Saved: {DIR_ASSETS}/{DIR_FONTS}/{font.filename}")

    def _generate_overview(self):
        """Generate REPORT.md summary."""
        self.log("[cyan]→ Generating REPORT.md...[/cyan]")

        total_lines = sum(css.line_count for css in self.css_files)
        timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        inline_count = len(self.inline_styles)
        external_count = len(self.external_css_urls)
        import_count = len(self.imported_urls_downloaded)
        category_counts = Counter(
            (font.category or "Other") for font in self.font_files
        )
        icon_count = category_counts.get("Icon", 0)
        typography_count = category_counts.get("Typography", 0)
        other_count = category_counts.get("Other", 0)

        md = f"""# CSS Extract: {self.base_domain}

This is an export from the CSS of {self.url}. Below are the key results from this extraction run.

Original file URLs are listed for reference; all assets are also saved locally in the folders below for direct use.

Disclaimer: This report is auto-generated from a web scrape and may contain miscategorized or incomplete data. Use it as a starting point, not a definitive source.

HTML snapshot: [{HTML_BODY_FILENAME}](./{HTML_BODY_FILENAME}) (body-only; scripts/iframes/styles removed and prettified for analysis).

## Snapshot

- Extracted at: {timestamp}
- Total CSS files: {len(self.css_files)}
- Total CSS lines: {total_lines:,}
- Inline style tags: {inline_count}
- External stylesheets: {external_count}
- Fonts downloaded: {len(self.font_files)}
- Font categories: Icon ({icon_count}), Typography ({typography_count}), Other ({other_count})
"""

        if import_count:
            md += f"- @import statements resolved: {import_count}\n"

        md += "\n## Stylesheets\n\n"

        if self.css_files:
            for css in self.css_files:
                local_path = f"./{css.local_path}"
                display_path = css.local_path.as_posix()
                source_label = "CDN" if css.source_type == "vendor" else css.source_type.title()
                md += (
                    f"- **[{display_path}]({local_path})**\n"
                    f"  Source: {source_label}\n"
                    f"  Lines: {css.line_count:,}\n"
                    f"  URL: {css.url}\n"
                )
                if css.summary_path:
                    summary_path = f"./{css.summary_path}"
                    md += (
                        f"  Summary: [{css.summary_path.as_posix()}]({summary_path})\n"
                        "  Note: Due to the high size of this file, a summary version was generated.\n"
                    )
                md += "\n"
        else:
            md += "- None detected\n\n"

        md += "## Fonts\n\n"
        fonts_by_category = {
            "Icon": [],
            "Typography": [],
            "Other": [],
        }
        for font in self.font_files:
            category = font.category or "Other"
            if category not in fonts_by_category:
                category = "Other"
            fonts_by_category[category].append(font)

        md += "### Icon Fonts\n\n"
        if fonts_by_category["Icon"]:
            for font in fonts_by_category["Icon"]:
                local_path = f"./{DIR_ASSETS}/{DIR_FONTS}/{font.filename}"
                family = font.family or "Unknown"
                md += (
                    f"- **[{font.filename}]({local_path})**\n"
                    f"  Format: {font.format}\n"
                    f"  Family: {family}\n"
                    f"  URL: {font.url}\n\n"
                )
        else:
            md += "- None detected\n\n"

        md += "### Typographies\n\n"
        if fonts_by_category["Typography"]:
            for font in fonts_by_category["Typography"]:
                local_path = f"./{DIR_ASSETS}/{DIR_FONTS}/{font.filename}"
                family = font.family or "Unknown"
                md += (
                    f"- **[{font.filename}]({local_path})**\n"
                    f"  Format: {font.format}\n"
                    f"  Family: {family}\n"
                    f"  URL: {font.url}\n\n"
                )
        else:
            md += "- None detected\n\n"

        md += "### Other Fonts\n\n"
        if fonts_by_category["Other"]:
            for font in fonts_by_category["Other"]:
                local_path = f"./{DIR_ASSETS}/{DIR_FONTS}/{font.filename}"
                family = font.family or "Unknown"
                md += (
                    f"- **[{font.filename}]({local_path})**\n"
                    f"  Format: {font.format}\n"
                    f"  Family: {family}\n"
                    f"  URL: {font.url}\n\n"
                )
        else:
            md += "- None detected\n\n"

        # Include errors only when explicitly requested.
        if self.show_errors:
            md += "## Errors\n\n"
            if self.errors:
                for error in self.errors:
                    md += f"- {error}\n"
                md += "\n"
            else:
                md += "- None\n\n"


        # Save overview
        overview_path = self.output_dir / REPORT_FILENAME
        overview_path.write_text(md, encoding="utf-8")
        self.log(f"  Saved: {REPORT_FILENAME}")


# === CLI ===

def main():
    parser = argparse.ArgumentParser(
        description="Extract and document CSS from any website.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  css-extract --url https://stripe.com
  css-extract --url https://linear.app --output ./designs/
  css-extract --url https://vercel.com --timeout 20 --quiet
        """
    )

    parser.add_argument(
        "--url", "-u",
        required=True,
        help="Target URL to extract CSS from"
    )
    parser.add_argument(
        "--output", "-o",
        default=str(DEFAULT_OUTPUT_DIR),
        help=f"Output directory (default: CSS-EXTRACT-RESULTS/ inside the CSS-EXTRACTOR folder)"
    )
    parser.add_argument(
        "--timeout", "-t",
        type=int,
        default=15,
        help="JS render timeout in seconds (default: 15)"
    )
    parser.add_argument(
        "--quiet", "-q",
        action="store_true",
        help="Suppress verbose output"
    )
    parser.add_argument(
        "--show-errors",
        action="store_true",
        help="Include errors section in REPORT.md"
    )
    parser.add_argument(
        "--summary-threshold",
        type=int,
        default=DEFAULT_SUMMARY_LINE_THRESHOLD,
        help=f"Line count threshold for CSS summaries (default: {DEFAULT_SUMMARY_LINE_THRESHOLD})"
    )

    args = parser.parse_args()

    # Validate URL
    if not args.url.startswith(("http://", "https://")):
        args.url = "https://" + args.url

    extractor = CSSExtractor(
        url=args.url,
        output_dir=args.output,
        timeout=args.timeout,
        quiet=args.quiet,
        show_errors=args.show_errors,
        summary_line_threshold=args.summary_threshold
    )

    success = extractor.run()
    sys.exit(0 if success else 1)


if __name__ == "__main__":
    main()
