from __future__ import annotations

from collections.abc import Callable
from pathlib import Path
from urllib.parse import urlparse

from markdownify import markdownify as md
from playwright.sync_api import sync_playwright
from pydantic_ai import Agent

from noisegate.exceptions import ProgramImportError
from noisegate.llm import _make_llm_error
from noisegate.models import Program

_MAX_PAGES = 10
_MAX_CHARS_PER_PAGE = 40_000

_CRAWL_SYSTEM_PROMPT = """\
You are a bug bounty program data extractor. You will be given a seed URL for a \
bug bounty program.

Use the fetch_page tool to retrieve the seed URL. You may then fetch additional URLs, \
but ONLY if they appear explicitly as links in content you have already fetched. \
Do not construct, guess, or invent URLs. Stop when you have enough information or \
have reached the page limit.

Extract and populate these fields:
- name: Official program name (e.g. "Acme Bug Bounty Program").
- platform: Hosting platform (e.g. "HackerOne", "Bugcrowd"). Infer from URL if not explicit.
- source: Primary/canonical URL for the program (use first seed URL if unclear).
- assets.include: In-scope assets — domains, subdomains, IP ranges, apps. Use wildcards \
  (e.g. "*.example.com") where the program does.
- assets.exclude: Explicitly out-of-scope assets.
- ineligible_findings: Finding types the program will not reward.
- required_report_sections: Information a valid report must include. Use short labels \
  (e.g. "Attack scenario", "Reproduction steps", "Recommended fix") — not full sentences.
- notes: Rules directly relevant to triaging reports — reward ranges, safe-harbour \
  clauses, disclosure timelines, coordinated disclosure requirements, researcher \
  conduct rules, finding-specific eligibility rules (e.g. XSS context requirements, \
  subdomain takeover PoC requirements), and scope edge cases. \
  Exclude: aggregate program statistics (total bounties paid, average bounty, response \
  times), legal/ToS boilerplate, URLs, links, submission forms, and anything not \
  needed to evaluate or respond to a specific report.

Be thorough. Use empty lists when there is no information — do not fabricate. \
Preserve domain names, product names, and technical terms exactly as written.\
"""


def _fetch_page(url: str) -> tuple[str, int]:
    """Fetch a JS-rendered URL with Playwright and return (markdown content, HTTP status).
    Raises ProgramImportError on navigation or extraction failure."""
    try:
        with sync_playwright() as p:
            with p.chromium.launch() as browser:
                page = browser.new_page()
                response = page.goto(url, wait_until="load", timeout=30_000)
                page.wait_for_timeout(3_000)  # allow SPA to finish rendering after load
                status = response.status if response else 0
                # Remove boilerplate from the live DOM before extracting HTML so
                # markdownify never sees their raw text content (markdownify's
                # `strip` option removes tags but keeps their text nodes).
                page.evaluate(
                    "['script','style','noscript','nav','footer','header']"
                    ".forEach(t => document.querySelectorAll(t).forEach(el => el.remove()))"
                )
                html = page.content()
    except ProgramImportError:
        raise
    except Exception as e:
        raise ProgramImportError(f"Failed to fetch {url}: {e}") from e

    content = md(html, heading_style="ATX").strip()
    if not content:
        raise ProgramImportError(f"Could not extract content from {url}")

    return content[:_MAX_CHARS_PER_PAGE], status


def _read_file_as_text(path: Path) -> str:
    """Extract text from a local file. Supports HTML, PDF, and plain text."""
    try:
        suffix = path.suffix.lower()
        if suffix in (".html", ".htm"):
            return md(path.read_text(), heading_style="ATX").strip()
        if suffix == ".pdf":
            from pdfminer.high_level import extract_text
            return str(extract_text(str(path))).strip()
        return path.read_text()
    except OSError as e:
        raise ProgramImportError(f"Failed to read {path}: {e}") from e


def import_program_from_file(
    path: Path,
    model: str,
    on_complete: Callable[[str, int, int], None] | None = None,
) -> Program:
    """Read a local file and synthesize a Program using the LLM.

    Supports HTML, PDF, and plain text. Raises ProgramImportError on read
    failure and LLMError on synthesis failure.
    """
    content = _read_file_as_text(path)
    if not content:
        raise ProgramImportError(f"Could not extract content from {path}")

    try:
        agent: Agent[None, Program] = Agent(
            model,
            output_type=Program,
            system_prompt=_CRAWL_SYSTEM_PROMPT,
        )
        result = agent.run_sync(
            f"Program policy content:\n\n{content[:_MAX_CHARS_PER_PAGE]}"
        )
    except Exception as e:
        raise _make_llm_error(e, model) from e

    if on_complete is not None:
        usage = result.usage()
        on_complete(model, usage.request_tokens or 0, usage.response_tokens or 0)

    return result.output


def import_program_from_url(
    seed_url: str,
    model: str,
    on_fetch_start: Callable[[str], None] | None = None,
    on_fetch: Callable[[str, int, str], None] | None = None,
    on_warning: Callable[[str], None] | None = None,
    on_complete: Callable[[str, int, int], None] | None = None,
) -> Program:
    """Crawl seed_url with an LLM agent and return a synthesized Program.
    Raises ProgramImportError on fetch failure or LLMError on synthesis failure."""
    fetched_urls: set[str] = set()
    allowed_host = urlparse(seed_url).hostname

    try:
        agent: Agent[None, Program] = Agent(
            model,
            output_type=Program,
            system_prompt=_CRAWL_SYSTEM_PROMPT,
        )
    except Exception as e:
        raise _make_llm_error(e, model) from e

    @agent.tool_plain
    def fetch_page(url: str) -> str:
        """Fetch a URL and return its markdown content."""
        if urlparse(url).hostname != allowed_host:
            return f"[Skipped: {url} — only pages on {allowed_host} are allowed.]"
        if len(fetched_urls) >= _MAX_PAGES:
            return f"[Page limit of {_MAX_PAGES} reached — no more fetches allowed.]"
        if url in fetched_urls:
            return f"[Already fetched: {url}]"
        fetched_urls.add(url)
        if on_fetch_start is not None:
            on_fetch_start(url)
        try:
            content, status = _fetch_page(url)
        except ProgramImportError as e:
            if on_warning is not None:
                on_warning(str(e))
            return f"[Failed to fetch {url}: {e}]"
        if on_fetch is not None:
            on_fetch(url, status, content)
        if status >= 400:
            return f"[HTTP {status} error fetching {url}]"
        return content

    user_message = f"Seed URL: {seed_url}"

    try:
        result = agent.run_sync(user_message)
    except Exception as e:
        raise _make_llm_error(e, model) from e

    if on_complete is not None:
        usage = result.usage()
        on_complete(model, usage.request_tokens or 0, usage.response_tokens or 0)

    return result.output
