"""Structured data inventory workflows for sitemap and Sheets URL sources."""

from __future__ import annotations

import asyncio
import csv
import json
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from html.parser import HTMLParser
from io import StringIO
from typing import Any, Iterable
from urllib.parse import quote

import requests
import wordlift_client
from google.auth.transport.requests import AuthorizedSession
from wordlift_sdk.render.browser import Browser
from wordlift_client import ApiClient, Configuration

from worai.core.concurrency import classify_status_bucket, parse_concurrency
from worai.core.google_oauth import SHEETS_SCOPES, load_google_credentials
from worai.core.http import DEFAULT_USER_AGENT
from worai.core.ingest import (
    DEFAULT_INGEST_LOADER,
    DEFAULT_INGEST_SOURCE,
    IngestSettings,
    resolve_ingest_settings,
)
from worai.core.url_sources import UrlRecord, UrlSourceOptions, resolve_url_records
from worai.errors import UsageError

DEFAULT_BASE_URL = "https://api.wordlift.io"
DEFAULT_TIMEOUT = 30.0

try:
    from tqdm import tqdm
except ImportError:  # pragma: no cover - tqdm provided transitively at runtime
    def tqdm(iterable, **_kwargs):  # type: ignore[no-redef]
        return iterable


@dataclass
class InventoryRow:
    url: str
    faq_markup: str
    faq_markup_from_graph: str
    types: str
    structured_data: str


@dataclass
class InventoryOptions:
    source: str
    api_key: str
    base_url: str = DEFAULT_BASE_URL
    timeout: float = DEFAULT_TIMEOUT
    source_sheet_name: str | None = None
    output: str | None = None
    destination_spreadsheet_id: str | None = None
    destination_sheet_name: str | None = None
    client_secrets: str | None = None
    token: str = "oauth_token.json"
    port: int = 8080
    concurrency: str = "auto"
    ingest_source: str | None = None
    ingest_loader: str | None = None
    ingest_passthrough_when_html: bool | None = None
    source_type: str | None = None


class IngestLoaderUnavailableError(RuntimeError):
    """Raised when a selected ingestion loader cannot run in this environment."""


class _JsonLdScriptParser(HTMLParser):
    def __init__(self) -> None:
        super().__init__(convert_charrefs=True)
        self._in_jsonld_script = False
        self._buf = StringIO()
        self.blocks: list[str] = []

    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
        if tag.lower() != "script":
            return
        attrs_map = {k.lower(): (v or "") for k, v in attrs}
        script_type = attrs_map.get("type", "").strip().lower()
        if script_type.startswith("application/ld+json"):
            self._in_jsonld_script = True
            self._buf = StringIO()

    def handle_data(self, data: str) -> None:
        if self._in_jsonld_script:
            self._buf.write(data)

    def handle_endtag(self, tag: str) -> None:
        if tag.lower() != "script" or not self._in_jsonld_script:
            return
        self._in_jsonld_script = False
        payload = self._buf.getvalue().strip()
        if payload:
            self.blocks.append(payload)


def _build_client(api_key: str, base_url: str) -> ApiClient:
    config = Configuration(host=base_url)
    config.api_key["ApiKey"] = api_key
    config.api_key_prefix["ApiKey"] = "Key"
    return ApiClient(config)


async def _get_dataset_uri_async(api_key: str, base_url: str) -> str:
    async with _build_client(api_key, base_url) as api_client:
        api = wordlift_client.AccountApi(api_client)
        account = await api.get_me()
    dataset_uri = getattr(account, "dataset_uri", None)
    if not dataset_uri:
        raise RuntimeError("Failed to resolve dataset_uri from account get_me.")
    return dataset_uri


def get_dataset_uri(api_key: str, base_url: str) -> str:
    return asyncio.run(_get_dataset_uri_async(api_key, base_url))


def _normalize_dataset_prefixes(dataset_uri: str) -> list[str]:
    base = dataset_uri.rstrip("/")
    prefixes = {dataset_uri, base, f"{base}/"}
    return sorted(p for p in prefixes if p)


def _strip_schema_prefix(type_name: str) -> str:
    value = type_name.strip()
    for prefix in (
        "http://schema.org/",
        "https://schema.org/",
        "schema.org/",
    ):
        if value.startswith(prefix):
            return value[len(prefix) :]
    return value


def _as_list(value: Any) -> list[Any]:
    if value is None:
        return []
    if isinstance(value, list):
        return value
    return [value]


def _extract_graph_nodes(value: Any) -> list[dict[str, Any]]:
    nodes: list[dict[str, Any]] = []
    stack: list[Any] = [value]

    while stack:
        current = stack.pop()
        if isinstance(current, list):
            stack.extend(reversed(current))
            continue
        if not isinstance(current, dict):
            continue

        graph_value = current.get("@graph")
        if isinstance(graph_value, list):
            stack.extend(reversed(graph_value))
            node = {k: v for k, v in current.items() if k not in {"@graph", "@context"}}
            if node:
                nodes.append(node)
            continue

        nodes.append(current)

    return nodes


def extract_jsonld_blocks(html: str) -> list[Any]:
    parser = _JsonLdScriptParser()
    parser.feed(html)
    parsed: list[Any] = []
    for block in parser.blocks:
        try:
            parsed.append(json.loads(block))
        except Exception:
            continue
    return parsed


def combine_jsonld_graph(blocks: list[Any]) -> dict[str, Any]:
    graph: list[dict[str, Any]] = []
    for block in blocks:
        graph.extend(_extract_graph_nodes(block))
    return {
        "@context": "https://schema.org",
        "@graph": graph,
    }


def _collect_types(nodes: Iterable[dict[str, Any]]) -> list[str]:
    out: list[str] = []
    seen: set[str] = set()
    for node in nodes:
        for raw_type in _as_list(node.get("@type")):
            if not isinstance(raw_type, str):
                continue
            type_name = _strip_schema_prefix(raw_type)
            if not type_name or type_name in seen:
                continue
            seen.add(type_name)
            out.append(type_name)
    return out


def _is_faq_page_node(node: dict[str, Any]) -> bool:
    for raw_type in _as_list(node.get("@type")):
        if isinstance(raw_type, str) and _strip_schema_prefix(raw_type) == "FAQPage":
            return True
    return False


def _has_faq_from_dataset(nodes: Iterable[dict[str, Any]], dataset_uri: str) -> tuple[bool, bool]:
    prefixes = _normalize_dataset_prefixes(dataset_uri)
    faq_found = False
    faq_from_graph = False

    for node in nodes:
        if not _is_faq_page_node(node):
            continue
        faq_found = True
        faq_id = node.get("@id")
        if isinstance(faq_id, str) and any(faq_id.startswith(prefix) for prefix in prefixes):
            faq_from_graph = True

    return faq_found, faq_from_graph


def _fetch_html(browser: Browser, url: str) -> tuple[str, int | None]:
    page, response, _elapsed_ms, _resources = browser.open(url)
    if page is None:
        raise RuntimeError("Failed to open page in browser.")
    status_code = getattr(response, "status", None) if response is not None else None
    try:
        if isinstance(status_code, int) and status_code >= 400:
            raise RuntimeError(f"Playwright page fetch failed with status {status_code}")
        return page.content(), status_code if isinstance(status_code, int) else None
    finally:
        page.close()


def _fetch_html_simple(url: str, *, timeout: float) -> tuple[str, int | None]:
    response = requests.get(
        url,
        timeout=timeout,
        headers={"User-Agent": DEFAULT_USER_AGENT},
    )
    return response.text, response.status_code


def _fetch_html_browser(
    url: str,
    *,
    timeout_ms: int,
    wait_until: str,
) -> tuple[str, int | None]:
    try:
        with Browser(
            headless=True,
            timeout_ms=timeout_ms,
            wait_until=wait_until,
            user_agent=DEFAULT_USER_AGENT,
        ) as browser:
            return _fetch_html(browser, url)
    except Exception as exc:  # pragma: no cover - depends on local Playwright setup
        message = str(exc).lower()
        if "playwright" in message or "chromium" in message:
            raise IngestLoaderUnavailableError(
                "Selected ingest loader requires Playwright/Chromium, but it is unavailable. "
                "Install with: playwright install chromium."
            ) from exc
        raise


def _resolve_loader(loader: str) -> str:
    if loader == "auto":
        return DEFAULT_INGEST_LOADER
    return loader


def _load_html_for_record(
    record: UrlRecord,
    *,
    ingest: IngestSettings,
    timeout: float,
    timeout_ms: int,
) -> tuple[str, int | None]:
    if ingest.passthrough_when_html and record.html is not None:
        return record.html, None

    loader = _resolve_loader(ingest.loader)
    if loader == "passthrough":
        if record.html is None:
            raise UsageError("INGEST_LOADER=passthrough requires source records with embedded HTML.")
        return record.html, None
    if loader == "simple":
        return _fetch_html_simple(record.url, timeout=timeout)
    if loader in {"playwright", "proxy", "premium_scraper", "web_scrape_api"}:
        return _fetch_html_browser(
            record.url,
            timeout_ms=timeout_ms,
            wait_until="networkidle",
        )

    raise UsageError(f"Unsupported ingest loader: {loader}")


def build_inventory_row(url: str, html: str, dataset_uri: str) -> InventoryRow:
    blocks = extract_jsonld_blocks(html)
    combined = combine_jsonld_graph(blocks)
    nodes = combined["@graph"]
    types = _collect_types(nodes)
    faq_markup, faq_markup_from_graph = _has_faq_from_dataset(nodes, dataset_uri)

    return InventoryRow(
        url=url,
        faq_markup="yes" if faq_markup else "no",
        faq_markup_from_graph="yes" if faq_markup_from_graph else "no",
        types=",".join(types),
        structured_data=json.dumps(combined, ensure_ascii=False, separators=(",", ":")),
    )


def build_inventory(options: InventoryOptions) -> list[InventoryRow]:
    dataset_uri = get_dataset_uri(options.api_key, options.base_url)
    ingest = resolve_ingest_settings(
        context="structured_data_inventory",
        new_source=options.ingest_source,
        new_loader=options.ingest_loader,
        new_passthrough_when_html=options.ingest_passthrough_when_html,
        legacy_source=options.source_type,
        default_source=DEFAULT_INGEST_SOURCE,
        default_loader=DEFAULT_INGEST_LOADER,
    )
    records = resolve_url_records(
        UrlSourceOptions(
            source=options.source,
            timeout=options.timeout,
            sheet_name=options.source_sheet_name,
            client_secrets=options.client_secrets,
            token=options.token,
            port=options.port,
            ingest_source=ingest.source,
            source_type=options.source_type,
        )
    )
    settings = parse_concurrency(
        options.concurrency,
        auto_default_workers=4,
        auto_min_workers=2,
        auto_max_workers=8,
    )
    timeout_ms = max(1, int(options.timeout * 1000))
    rows: list[InventoryRow] = []
    empty_graph = json.dumps({"@context": "https://schema.org", "@graph": []})

    index = 0
    total = len(records)
    current_workers = settings.workers
    with tqdm(total=total, desc="Inventory", unit="url") as progress:
        while index < total:
            batch = records[index : index + current_workers]
            if not batch:
                break

            def _worker(record: UrlRecord) -> tuple[InventoryRow, str]:
                status_code: int | None = None
                try:
                    html, status_code = _load_html_for_record(
                        record,
                        ingest=ingest,
                        timeout=options.timeout,
                        timeout_ms=timeout_ms,
                    )
                    return (
                        build_inventory_row(record.url, html, dataset_uri),
                        classify_status_bucket(status_code),
                    )
                except IngestLoaderUnavailableError:
                    raise
                except Exception:
                    return (
                        InventoryRow(
                            url=record.url,
                            faq_markup="no",
                            faq_markup_from_graph="no",
                            types="",
                            structured_data=empty_graph,
                        ),
                        classify_status_bucket(status_code, had_error=True),
                    )

            batch_rows: dict[int, InventoryRow] = {}
            batch_buckets: list[str] = []
            with ThreadPoolExecutor(max_workers=current_workers) as executor:
                future_map = {
                    executor.submit(_worker, record): batch_index
                    for batch_index, record in enumerate(batch)
                }
                for future in as_completed(future_map):
                    batch_index = future_map[future]
                    try:
                        row, bucket = future.result()
                    except IngestLoaderUnavailableError:
                        raise
                    except Exception:
                        row = InventoryRow(
                            url=batch[batch_index].url,
                            faq_markup="no",
                            faq_markup_from_graph="no",
                            types="",
                            structured_data=empty_graph,
                        )
                        bucket = "error"
                    batch_rows[batch_index] = row
                    batch_buckets.append(bucket)
                    progress.update(1)

            for batch_index in range(len(batch)):
                rows.append(batch_rows[batch_index])

            if settings.is_auto:
                current_workers = settings.with_workers(current_workers).next_workers(batch_buckets)
            index += len(batch)

    return rows


def _headers() -> list[str]:
    return ["url", "faq_markup", "faq_markup_from_graph", "types", "structured_data"]


def write_csv(path: str, rows: list[InventoryRow]) -> None:
    with open(path, "w", newline="", encoding="utf-8") as handle:
        writer = csv.DictWriter(handle, fieldnames=_headers())
        writer.writeheader()
        for row in rows:
            writer.writerow(row.__dict__)


def _get_sheet_titles(session: AuthorizedSession, spreadsheet_id: str) -> list[str]:
    url = f"https://sheets.googleapis.com/v4/spreadsheets/{spreadsheet_id}?fields=sheets.properties.title"
    response = session.get(url)
    if response.status_code != 200:
        raise RuntimeError(f"Failed to read spreadsheet metadata ({response.status_code}): {response.text}")
    data = response.json()
    sheets = data.get("sheets") or []
    titles: list[str] = []
    for sheet in sheets:
        title = ((sheet or {}).get("properties") or {}).get("title")
        if isinstance(title, str) and title:
            titles.append(title)
    return titles


def _ensure_sheet_exists(
    session: AuthorizedSession,
    spreadsheet_id: str,
    sheet_title: str,
) -> None:
    if sheet_title in _get_sheet_titles(session, spreadsheet_id):
        return
    url = f"https://sheets.googleapis.com/v4/spreadsheets/{spreadsheet_id}:batchUpdate"
    payload = {
        "requests": [
            {
                "addSheet": {
                    "properties": {"title": sheet_title},
                }
            }
        ]
    }
    response = session.post(url, json=payload)
    if response.status_code != 200:
        raise RuntimeError(
            f"Failed to create destination sheet '{sheet_title}' ({response.status_code}): {response.text}"
        )


def write_google_sheet(
    spreadsheet_id: str,
    sheet_name: str,
    rows: list[InventoryRow],
    *,
    client_secrets: str | None,
    token: str,
    port: int,
) -> None:
    creds = load_google_credentials(
        client_secrets_path=client_secrets,
        token_path=token,
        port=port,
        scopes=SHEETS_SCOPES,
    )
    session = AuthorizedSession(creds)

    _ensure_sheet_exists(session, spreadsheet_id, sheet_name)
    range_name = f"'{sheet_name}'!A1"
    encoded_range = quote(range_name, safe="")

    clear_url = (
        f"https://sheets.googleapis.com/v4/spreadsheets/{spreadsheet_id}/values/{encoded_range}:clear"
    )
    clear_resp = session.post(clear_url, json={})
    if clear_resp.status_code not in {200, 204}:
        raise RuntimeError(f"Failed to clear spreadsheet ({clear_resp.status_code}): {clear_resp.text}")

    values: list[list[str]] = [_headers()]
    for row in rows:
        values.append([
            row.url,
            row.faq_markup,
            row.faq_markup_from_graph,
            row.types,
            row.structured_data,
        ])

    update_url = (
        f"https://sheets.googleapis.com/v4/spreadsheets/{spreadsheet_id}/values/{encoded_range}"
        "?valueInputOption=RAW"
    )
    payload = {
        "range": range_name,
        "majorDimension": "ROWS",
        "values": values,
    }
    update_resp = session.put(update_url, json=payload)
    if update_resp.status_code != 200:
        raise RuntimeError(f"Failed to write spreadsheet ({update_resp.status_code}): {update_resp.text}")


def run_inventory(options: InventoryOptions) -> list[InventoryRow]:
    if bool(options.output) == bool(options.destination_spreadsheet_id):
        raise UsageError(
            "Provide exactly one destination: --output or --destination-sheet-id."
        )
    if options.destination_spreadsheet_id and not options.destination_sheet_name:
        raise UsageError(
            "--destination-sheet-name is required with --destination-sheet-id."
        )

    rows = build_inventory(options)

    if options.output:
        write_csv(options.output, rows)
    if options.destination_spreadsheet_id:
        write_google_sheet(
            options.destination_spreadsheet_id,
            options.destination_sheet_name or "",
            rows,
            client_secrets=options.client_secrets,
            token=options.token,
            port=options.port,
        )

    return rows
