"""Crawler — BFS discovery of application pages.

Starting from the configured app_url, the crawler visits every internal link
it finds, analyses each page, and builds a NavigationGraph.
"""

from __future__ import annotations

import logging
from collections import deque
from pathlib import Path
from typing import Callable

from playwright.sync_api import Page

from antiqa.browser.manager import BrowserManager
from antiqa.config.schema import BrowserConfig

from .graph import Edge, NavigationGraph, PageNode
from .page_analyzer import PageAnalyzer

log = logging.getLogger("antiqa.crawler")

DEFAULT_GRAPH_PATH = "crawler/nav_graph.json"


class Crawler:
    """BFS web crawler that builds a NavigationGraph."""

    def __init__(
        self,
        base_url: str,
        browser_config: BrowserConfig,
        max_pages: int = 50,
        max_depth: int = 5,
        include_patterns: list[str] | None = None,
        exclude_patterns: list[str] | None = None,
        on_page: Callable[[str, int, int], None] | None = None,
    ) -> None:
        self.base_url = base_url.rstrip("/")
        self.browser_config = browser_config
        self.max_pages = max_pages
        self.max_depth = max_depth
        self.include_patterns = include_patterns or []
        self.exclude_patterns = exclude_patterns or ["/logout", "/api/"]
        self.on_page = on_page  # callback(url, visited_count, total_queued)

    def _should_visit(self, url: str) -> bool:
        """Apply include/exclude filters."""
        for pattern in self.exclude_patterns:
            if pattern in url:
                return False
        if self.include_patterns:
            return any(pattern in url for pattern in self.include_patterns)
        return True

    def crawl(
        self,
        login_fn: Callable[[Page], None] | None = None,
        storage_state: str | Path | None = None,
    ) -> NavigationGraph:
        """Run a BFS crawl and return the resulting NavigationGraph.

        Args:
            login_fn: Optional callback that performs login on the given page
                      before crawling starts.
            storage_state: Path to a saved session state to restore.
        """
        graph = NavigationGraph()
        mgr = BrowserManager(self.browser_config)
        page = mgr.launch(storage_state=storage_state)

        try:
            if login_fn:
                log.info("Running login before crawl")
                login_fn(page)

            analyzer = PageAnalyzer(page, self.base_url)

            # BFS queue: (url, depth, referrer_pattern)
            queue: deque[tuple[str, int, str | None]] = deque()
            queue.append((self.base_url, 0, None))
            visited_patterns: set[str] = set()
            visited_count = 0

            while queue and visited_count < self.max_pages:
                url, depth, referrer = queue.popleft()

                if depth > self.max_depth:
                    continue

                pattern = analyzer.url_to_pattern(url)
                if pattern in visited_patterns:
                    if referrer:
                        graph.add_edge(Edge(
                            source=referrer, target=pattern,
                            action="click_link", label=url,
                        ))
                    continue

                if not self._should_visit(url):
                    continue

                log.info("Crawling [%d/%d] depth=%d %s", visited_count + 1, self.max_pages, depth, url)

                try:
                    response = page.goto(url, wait_until="domcontentloaded", timeout=15_000)
                    if response and response.status >= 400:
                        log.warning("Skipping %s (HTTP %d)", url, response.status)
                        continue
                    page.wait_for_timeout(500)
                except Exception as exc:
                    log.warning("Failed to load %s: %s", url, exc)
                    continue

                actual_pattern = analyzer.url_to_pattern(page.url)
                visited_patterns.add(actual_pattern)
                visited_count += 1

                if self.on_page:
                    self.on_page(page.url, visited_count, len(queue))

                meta = analyzer.analyze()
                node = PageNode(
                    url=page.url,
                    url_pattern=actual_pattern,
                    title=meta["title"],
                    links=meta["links"],
                    forms=meta["forms"],
                    buttons=meta["buttons"],
                    inputs=meta["inputs"],
                )
                graph.add_node(node)

                if referrer:
                    graph.add_edge(Edge(
                        source=referrer, target=actual_pattern,
                        action="click_link", label=url,
                    ))

                for link_url in meta["links"]:
                    link_pattern = analyzer.url_to_pattern(link_url)
                    if link_pattern not in visited_patterns:
                        queue.append((link_url, depth + 1, actual_pattern))

            log.info(
                "Crawl complete: %d pages, %d edges",
                graph.page_count, graph.edge_count,
            )
        finally:
            mgr.close()

        return graph

    def crawl_and_save(
        self,
        output_path: str | Path = DEFAULT_GRAPH_PATH,
        login_fn: Callable[[Page], None] | None = None,
        storage_state: str | Path | None = None,
        merge_existing: bool = True,
    ) -> NavigationGraph:
        """Crawl, optionally merge with existing graph, and save to disk."""
        graph = self.crawl(login_fn=login_fn, storage_state=storage_state)

        path = Path(output_path)
        if merge_existing and path.is_file():
            log.info("Merging with existing graph at %s", path)
            existing = NavigationGraph.load(path)
            existing.merge(graph)
            graph = existing

        graph.save(path)
        return graph
