"""Graph sync workflow integration."""

from __future__ import annotations

import asyncio
import ast
import json
import logging
import os
import re
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from worai.core.ingest import DEFAULT_INGEST_LOADER, resolve_ingest_settings

try:
    from wordlift_sdk.configuration import ConfigurationProvider
    from wordlift_sdk.kg_build import (
        ProfileImportProtocol,
        load_profile_config,
    )
    from wordlift_sdk.kg_build.cloud_flow import CloudWorkflowConfig, run_cloud_workflow
    from wordlift_sdk.kg_build.container import KgBuildApplicationContainer
except ModuleNotFoundError:  # pragma: no cover - environment dependent
    ConfigurationProvider = None
    ProfileImportProtocol = None
    load_profile_config = None
    CloudWorkflowConfig = None
    run_cloud_workflow = None
    KgBuildApplicationContainer = None


LOGGER = logging.getLogger("worai.graph")
_TIMEOUT_RE = re.compile(r"^(?P<value>\d+(?:\.\d+)?)(?P<unit>ms|s)?$")


@dataclass
class GraphSyncOptions:
    profile: str
    config_path: Path = Path("worai.toml")
    debug: bool = False


def load_service_account_json(raw_value: str | None) -> str:
    if not raw_value:
        raise ValueError("Missing profile setting: sheets_service_account")

    candidate_path = Path(raw_value)
    if candidate_path.exists() and candidate_path.is_file():
        return candidate_path.read_text(encoding="utf-8")

    try:
        parsed = json.loads(raw_value)
    except json.JSONDecodeError as exc:
        raise ValueError("sheets_service_account must be JSON content or a file path") from exc

    if not isinstance(parsed, dict):
        raise ValueError("sheets_service_account JSON must be an object")
    return raw_value


def build_extra_settings(profile_settings: dict[str, Any]) -> dict[str, Any]:
    consumed = {
        "urls",
        "sitemap_url",
        "sitemap_url_pattern",
        "sheets_url",
        "sheets_name",
        "sheets_service_account",
        "overwrite",
        "concurrency",
        "web_page_import_mode",
        "web_page_import_timeout",
        "ingest_timeout_ms",
        "ingest_playwright_wait_until",
        "playwright_wait_until",
        "ingest_source",
        "ingest_loader",
        "ingest_passthrough_when_html",
        "google_search_console",
        "shacl_validate_sync",
        "shacl_validate_mode",
        "shacl_shape_specs",
        "shacl_builtin_shapes",
        "shacl_exclude_builtin_shapes",
        "shacl_extra_shapes",
        "canonical_id_strategy",
    }
    return {key.upper(): value for key, value in profile_settings.items() if key not in consumed}


def _append_py_setting(lines: list[str], key: str, value: Any) -> None:
    lines.append(f"{key} = {repr(value)}")


def _using_sheets_source(settings: dict[str, Any]) -> bool:
    return bool(settings.get("sheets_url") or settings.get("sheets_name"))


def _parse_bool(value: Any, *, name: str) -> bool:
    if isinstance(value, bool):
        return value
    if isinstance(value, str):
        lowered = value.strip().lower()
        if lowered in {"1", "true", "yes", "on"}:
            return True
        if lowered in {"0", "false", "no", "off"}:
            return False
    raise ValueError(f"{name} must be a boolean")


def _resolve_web_page_import_timeout_ms(value: Any) -> int:
    if value is None:
        raise ValueError("web_page_import_timeout must not be empty")

    parsed: float
    explicit_ms = False
    explicit_s = False
    if isinstance(value, (int, float)):
        parsed = float(value)
    elif isinstance(value, str):
        token = value.strip().lower()
        if not token:
            raise ValueError("web_page_import_timeout must not be empty")
        match = _TIMEOUT_RE.match(token)
        if not match:
            raise ValueError(
                "web_page_import_timeout must be a number of seconds (e.g. 60 or 60s) "
                "or milliseconds (e.g. 60000ms)"
            )
        parsed = float(match.group("value"))
        explicit_ms = match.group("unit") == "ms"
        explicit_s = match.group("unit") == "s"
    else:
        raise ValueError(
            "web_page_import_timeout must be numeric seconds or a string with optional s/ms suffix"
        )

    if parsed <= 0:
        raise ValueError("web_page_import_timeout must be greater than 0")

    if explicit_ms:
        return int(parsed)
    if explicit_s:
        return int(parsed * 1000)

    return int(parsed * 1000)


def _resolve_playwright_wait_until(value: Any) -> str:
    if not isinstance(value, str):
        raise ValueError("playwright_wait_until must be a string")
    normalized = value.strip().lower()
    if normalized not in {"domcontentloaded", "load", "networkidle"}:
        raise ValueError("playwright_wait_until must be one of: domcontentloaded, load, networkidle")
    return normalized


def _resolve_canonical_id_strategy(value: Any) -> str:
    if not isinstance(value, str):
        raise ValueError("canonical_id_strategy must be a string")
    normalized = value.strip().lower()
    if normalized not in {"legacy", "dependency_graph"}:
        raise ValueError("canonical_id_strategy must be one of: legacy, dependency_graph")
    return normalized


def _resolve_ingest_timeout_ms(settings: dict[str, Any]) -> int | None:
    modern_value = settings.get("ingest_timeout_ms")
    if modern_value is not None:
        if isinstance(modern_value, bool):
            raise ValueError("ingest_timeout_ms must be an integer number of milliseconds")
        try:
            parsed = int(modern_value)
        except (TypeError, ValueError) as exc:
            raise ValueError(f"ingest_timeout_ms must be an integer number of milliseconds: {modern_value}") from exc
        if parsed <= 0:
            raise ValueError("ingest_timeout_ms must be greater than 0")
        return parsed
    legacy_value = settings.get("web_page_import_timeout")
    if legacy_value is not None:
        return _resolve_web_page_import_timeout_ms(legacy_value)
    return None


def _normalize_string_list(value: Any, *, name: str) -> list[str]:
    if value is None:
        return []
    if isinstance(value, str):
        item = value.strip()
        return [item] if item else []
    if isinstance(value, (list, tuple, set)):
        normalized: list[str] = []
        for item in value:
            if not isinstance(item, str):
                raise ValueError(f"{name} entries must be strings")
            stripped = item.strip()
            if stripped:
                normalized.append(stripped)
        return normalized
    raise ValueError(f"{name} must be a string or list of strings")


def _normalize_shacl_validate_mode(value: Any) -> str:
    if not isinstance(value, str):
        raise ValueError("shacl_validate_mode must be a string")
    mode = value.strip().lower()
    if mode in {"warn", "fail", "off"}:
        return mode
    raise ValueError("shacl_validate_mode must be one of: warn, fail, off")


def _extract_shacl_validation_settings(settings: dict[str, Any]) -> dict[str, Any]:
    resolved: dict[str, Any] = {}

    if "shacl_validate_sync" in settings and settings.get("shacl_validate_sync") is not None:
        raise ValueError("shacl_validate_sync is no longer supported. Use shacl_validate_mode.")
    if "shacl_shape_specs" in settings and settings.get("shacl_shape_specs") is not None:
        raise ValueError(
            "shacl_shape_specs is no longer supported. "
            "Use shacl_builtin_shapes/shacl_exclude_builtin_shapes/shacl_extra_shapes."
        )

    if "shacl_validate_mode" in settings and settings.get("shacl_validate_mode") is not None:
        resolved["SHACL_VALIDATE_MODE"] = _normalize_shacl_validate_mode(settings.get("shacl_validate_mode"))

    if "shacl_builtin_shapes" in settings and settings.get("shacl_builtin_shapes") is not None:
        resolved["SHACL_BUILTIN_SHAPES"] = _normalize_string_list(
            settings.get("shacl_builtin_shapes"),
            name="shacl_builtin_shapes",
        )
    if "shacl_exclude_builtin_shapes" in settings and settings.get("shacl_exclude_builtin_shapes") is not None:
        resolved["SHACL_EXCLUDE_BUILTIN_SHAPES"] = _normalize_string_list(
            settings.get("shacl_exclude_builtin_shapes"),
            name="shacl_exclude_builtin_shapes",
        )
    if "shacl_extra_shapes" in settings and settings.get("shacl_extra_shapes") is not None:
        resolved["SHACL_EXTRA_SHAPES"] = _normalize_string_list(
            settings.get("shacl_extra_shapes"),
            name="shacl_extra_shapes",
        )

    return resolved


def resolve_google_search_console(config_path: Path, profile_name: str) -> bool:
    try:
        data = tomllib.loads(config_path.read_text(encoding="utf-8"))
    except FileNotFoundError:
        return False
    except tomllib.TOMLDecodeError as exc:
        raise ValueError(f"Invalid TOML config: {config_path}: {exc}") from exc

    global_value = data.get("google_search_console", False)
    profile_table = data.get("profile") or data.get("profiles") or {}
    profile_value = profile_table.get(profile_name, {}).get("google_search_console")
    selected = profile_value if profile_value is not None else global_value
    return _parse_bool(selected, name="google_search_console")


def resolve_postprocessor_runtime(config_path: Path, profile_name: str) -> str | None:
    try:
        data = tomllib.loads(config_path.read_text(encoding="utf-8"))
    except FileNotFoundError:
        return None
    except tomllib.TOMLDecodeError as exc:
        raise ValueError(f"Invalid TOML config: {config_path}: {exc}") from exc

    profile_table = data.get("profile") or data.get("profiles") or {}
    profile_value = profile_table.get(profile_name, {}).get("postprocessor_runtime")
    global_value = data.get("postprocessor_runtime")
    selected = profile_value if profile_value is not None else global_value
    if selected is None:
        return None
    if not isinstance(selected, str):
        raise ValueError("postprocessor_runtime must be a string")

    runtime = selected.strip().lower()
    if runtime not in {"oneshot", "persistent"}:
        raise ValueError("postprocessor_runtime must be one of: oneshot, persistent")
    return runtime


def _build_dynamic_settings(
    profile_api_key: str,
    settings: dict[str, Any],
    *,
    google_search_console: bool,
) -> tuple[list[str], str | None]:
    lines = [f"WORDLIFT_KEY = {repr(profile_api_key)}"]
    sheets_service_account_json: str | None = None

    urls = settings.get("urls")
    sitemap_url = settings.get("sitemap_url")
    sitemap_url_pattern = settings.get("sitemap_url_pattern")
    sheets_url = settings.get("sheets_url")
    sheets_name = settings.get("sheets_name")
    using_sheets = _using_sheets_source(settings)
    source_modes = int(bool(urls)) + int(bool(sitemap_url)) + int(using_sheets)
    if source_modes != 1:
        raise ValueError(
            "Exactly one source is required: urls, sitemap_url, or sheets_url+sheets_name+sheets_service_account"
        )
    ingest = resolve_ingest_settings(
        context="graph_sync",
        new_source=settings.get("ingest_source"),
        new_loader=settings.get("ingest_loader"),
        new_passthrough_when_html=settings.get("ingest_passthrough_when_html"),
        legacy_loader=settings.get("web_page_import_mode"),
        default_loader=DEFAULT_INGEST_LOADER,
    )

    if urls:
        _append_py_setting(lines, "URLS", list(urls))
    elif sitemap_url:
        _append_py_setting(lines, "SITEMAP_URL", sitemap_url)
        if sitemap_url_pattern:
            _append_py_setting(lines, "SITEMAP_URL_PATTERN", sitemap_url_pattern)
    elif using_sheets:
        if not sheets_url or not sheets_name:
            raise ValueError("Both sheets_url and sheets_name are required when using Google Sheets source")
        sheets_service_account_json = load_service_account_json(settings.get("sheets_service_account"))
        _append_py_setting(lines, "SHEETS_URL", sheets_url)
        _append_py_setting(lines, "SHEETS_NAME", sheets_name)
    else:
        raise ValueError(
            "Exactly one source is required: urls, sitemap_url, or sheets_url+sheets_name+sheets_service_account"
        )

    source_value = ingest.source
    if source_value == "auto":
        if urls:
            source_value = "urls"
        elif sitemap_url:
            source_value = "sitemap"
        elif using_sheets:
            source_value = "sheets"
    _append_py_setting(lines, "INGEST_SOURCE", source_value)
    _append_py_setting(lines, "INGEST_LOADER", ingest.loader)
    _append_py_setting(lines, "INGEST_PASSTHROUGH_WHEN_HTML", ingest.passthrough_when_html)
    resolved_ingest_timeout_ms = _resolve_ingest_timeout_ms(settings)
    if resolved_ingest_timeout_ms is not None:
        _append_py_setting(lines, "INGEST_TIMEOUT_MS", resolved_ingest_timeout_ms)
    raw_wait_until = settings.get("ingest_playwright_wait_until", settings.get("playwright_wait_until"))
    if raw_wait_until is not None:
        _append_py_setting(
            lines,
            "PLAYWRIGHT_WAIT_UNTIL",
            _resolve_playwright_wait_until(raw_wait_until),
        )
    _append_py_setting(lines, "CONCURRENCY", int(settings.get("concurrency", 4)))
    _append_py_setting(lines, "OVERWRITE", bool(settings.get("overwrite", False)))
    _append_py_setting(lines, "GOOGLE_SEARCH_CONSOLE", google_search_console)
    for key, value in _extract_shacl_validation_settings(settings).items():
        _append_py_setting(lines, key, value)

    raw_canonical_id_strategy = settings.get("canonical_id_strategy")
    if raw_canonical_id_strategy is not None:
        _append_py_setting(
            lines,
            "CANONICAL_ID_STRATEGY",
            _resolve_canonical_id_strategy(raw_canonical_id_strategy),
        )

    for key, value in build_extra_settings(settings).items():
        _append_py_setting(lines, key, value)

    return lines, sheets_service_account_json


def _parse_settings_lines(lines: list[str]) -> dict[str, Any]:
    settings: dict[str, Any] = {}
    for line in lines:
        if "=" not in line:
            continue
        key, raw_value = line.split("=", 1)
        settings[key.strip()] = ast.literal_eval(raw_value.strip())
    return settings


def _build_cloud_workflow_config(
    *,
    lines: list[str],
    sheets_service_account_json: str | None,
    debug: bool,
    debug_profile_name: str,
) -> Any | None:
    if CloudWorkflowConfig is None:
        raise ValueError("wordlift-sdk 6.0.0+ cloud workflow config is unavailable in this environment")

    settings = _parse_settings_lines(lines)
    kwargs: dict[str, Any] = {
        "wordlift_key": settings.get("WORDLIFT_KEY"),
        "sheets_service_account_json": sheets_service_account_json,
        "overwrite": settings.get("OVERWRITE", False),
        "concurrency": settings.get("CONCURRENCY", 4),
        "ingest_loader": settings.get("INGEST_LOADER", "web_scrape_api"),
        "debug": debug,
        "debug_profile_name": debug_profile_name,
    }
    if "INGEST_TIMEOUT_MS" in settings:
        kwargs["ingest_timeout_ms"] = settings["INGEST_TIMEOUT_MS"]
    if "PLAYWRIGHT_WAIT_UNTIL" in settings:
        kwargs["playwright_wait_until"] = settings["PLAYWRIGHT_WAIT_UNTIL"]

    using_urls = "URLS" in settings
    using_sitemap = "SITEMAP_URL" in settings
    using_sheets = "SHEETS_URL" in settings or "SHEETS_NAME" in settings
    source_modes = int(using_urls) + int(using_sitemap) + int(using_sheets)
    if source_modes != 1:
        raise ValueError("Exactly one source mode is required: urls, sitemap_url, or sheets_url+sheets_name")

    if using_urls:
        kwargs["urls"] = settings["URLS"]
    elif using_sitemap:
        kwargs["sitemap_url"] = settings["SITEMAP_URL"]
        if "SITEMAP_URL_PATTERN" in settings:
            kwargs["sitemap_url_pattern"] = settings["SITEMAP_URL_PATTERN"]
    else:
        if "SHEETS_URL" not in settings or "SHEETS_NAME" not in settings:
            raise ValueError("Both sheets_url and sheets_name are required when using Google Sheets source")
        kwargs["sheets_url"] = settings["SHEETS_URL"]
        kwargs["sheets_name"] = settings["SHEETS_NAME"]

    consumed = {
        "WORDLIFT_KEY",
        "URLS",
        "SITEMAP_URL",
        "SITEMAP_URL_PATTERN",
        "SHEETS_URL",
        "SHEETS_NAME",
        "OVERWRITE",
        "CONCURRENCY",
        "INGEST_LOADER",
        "INGEST_TIMEOUT_MS",
        "PLAYWRIGHT_WAIT_UNTIL",
    }
    extra_settings = {key: value for key, value in settings.items() if key not in consumed}
    kwargs["extra_settings"] = extra_settings or None

    try:
        return CloudWorkflowConfig(**kwargs)
    except TypeError as exc:
        raise ValueError(
            "Installed wordlift-sdk does not match the expected 6.x cloud workflow contract."
        ) from exc


def _format_validation_summary(validation: Any) -> str:
    if validation is None:
        return "validation=disabled"
    if not isinstance(validation, dict):
        return f"validation={validation}"

    total = validation.get("total")
    passed = validation.get("pass")
    failed = validation.get("fail")
    warnings = (validation.get("warnings") or {}).get("count")
    errors = (validation.get("errors") or {}).get("count")
    return (
        f"validation(total={total}, pass={passed}, fail={failed}, "
        f"warnings={warnings}, errors={errors})"
    )


def _on_cloud_info(message: str) -> None:
    LOGGER.info(message)


def _on_cloud_progress(payload: dict[str, Any]) -> None:
    if not isinstance(payload, dict):
        LOGGER.info("Graph sync progress: %s", payload)
        return

    graph = payload.get("graph") or {}
    kind = payload.get("kind", "graph")
    location = payload.get("url") or payload.get("profile") or "n/a"
    LOGGER.info(
        "Progress[%s] %s entities=%s types=%s properties=%s %s",
        kind,
        location,
        graph.get("entities"),
        graph.get("type_assertions"),
        graph.get("property_assertions"),
        _format_validation_summary(payload.get("validation")),
    )
    LOGGER.debug("Progress payload: %s", json.dumps(payload, sort_keys=True, ensure_ascii=True))


def _on_cloud_kpi(payload: dict[str, Any]) -> None:
    if not isinstance(payload, dict):
        LOGGER.info("Graph sync KPI: %s", payload)
        return

    totals = payload.get("totals") or {}
    validation = payload.get("validation")
    LOGGER.info(
        "KPI profile=%s run_id=%s total_entities=%s type_assertions_total=%s property_assertions_total=%s",
        payload.get("profile"),
        payload.get("run_id"),
        totals.get("total_entities"),
        totals.get("type_assertions_total"),
        totals.get("property_assertions_total"),
    )
    LOGGER.info("KPI %s", _format_validation_summary(validation))
    if isinstance(validation, dict):
        total = validation.get("total")
        passed = validation.get("pass")
        failed = validation.get("fail")
        if isinstance(total, int) and isinstance(passed, int) and isinstance(failed, int):
            if total != passed + failed:
                LOGGER.warning(
                    "KPI validation invariant mismatch: total=%s pass=%s fail=%s",
                    total,
                    passed,
                    failed,
                )
    LOGGER.debug("KPI payload: %s", json.dumps(payload, sort_keys=True, ensure_ascii=True))


async def _run_cloud_workflow_with_callbacks(
    *,
    lines: list[str],
    sheets_service_account_json: str | None,
    debug: bool,
    debug_profile_name: str,
    protocol_factory,
) -> None:
    if (
        ConfigurationProvider is None
        or KgBuildApplicationContainer is None
        or run_cloud_workflow is None
    ):
        raise ValueError("wordlift-sdk 6.0.0+ cloud workflow support is unavailable in this environment")

    config = _build_cloud_workflow_config(
        lines=lines,
        sheets_service_account_json=sheets_service_account_json,
        debug=debug,
        debug_profile_name=debug_profile_name,
    )
    kwargs: dict[str, Any] = {
        "config": config,
        "configuration_provider_create": ConfigurationProvider.create,
        "container_factory": KgBuildApplicationContainer,
        "protocol_factory": protocol_factory,
        "on_info": _on_cloud_info,
        "on_progress": _on_cloud_progress,
        "on_kpi": _on_cloud_kpi,
    }
    await run_cloud_workflow(**kwargs)


async def run_graph_sync_async(options: GraphSyncOptions) -> None:
    if (
        ConfigurationProvider is None
        or ProfileImportProtocol is None
        or load_profile_config is None
        or KgBuildApplicationContainer is None
    ):
        raise ValueError("wordlift-sdk kg_build support is unavailable in this Python environment")

    config = load_profile_config(options.config_path)
    profile = config.get(options.profile)
    if profile is None:
        raise ValueError(f"Profile '{options.profile}' not found in {options.config_path}")
    if not profile.api_key:
        raise ValueError(f"Profile '{profile.name}' is missing api_key")

    settings = dict(profile.settings)
    google_search_console = resolve_google_search_console(options.config_path, options.profile)
    postprocessor_runtime = resolve_postprocessor_runtime(options.config_path, options.profile)
    lines, sheets_service_account_json = _build_dynamic_settings(
        profile.api_key,
        settings,
        google_search_console=google_search_console,
    )

    async def protocol_factory(context, debug_dir=None, workflow_config=None, on_progress=None):
        return ProfileImportProtocol(
            context=context,
            profile=profile,
            root_dir=Path.cwd(),
            debug_dir=debug_dir,
            on_progress=on_progress,
        )

    previous_postprocessor_runtime = os.environ.get("POSTPROCESSOR_RUNTIME")
    try:
        if postprocessor_runtime is not None:
            os.environ["POSTPROCESSOR_RUNTIME"] = postprocessor_runtime

        await _run_cloud_workflow_with_callbacks(
            lines=lines,
            sheets_service_account_json=sheets_service_account_json,
            debug=options.debug,
            debug_profile_name=profile.name,
            protocol_factory=protocol_factory,
        )
    finally:
        if postprocessor_runtime is not None:
            if previous_postprocessor_runtime is None:
                os.environ.pop("POSTPROCESSOR_RUNTIME", None)
            else:
                os.environ["POSTPROCESSOR_RUNTIME"] = previous_postprocessor_runtime


def run_graph_sync(options: GraphSyncOptions) -> None:
    asyncio.run(run_graph_sync_async(options))
