from __future__ import annotations

from dataclasses import dataclass
import hashlib
import logging
from pathlib import Path
import re
import subprocess
import sys

from typer.testing import CliRunner

from worai import cli as root_cli
from worai.commands import graph as graph_cmd
from worai.core.graph import create as graph_create
from worai.core.graph import sync as graph_sync
from worai.errors import UsageError


@dataclass
class _FakeProfile:
    name: str
    api_key: str | None
    settings: dict


_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m")


def _normalize_cli_output(text: str) -> str:
    return _ANSI_ESCAPE_RE.sub("", text)


def _patch_sdk_availability(monkeypatch) -> None:
    class _ConfigurationProvider:
        @staticmethod
        def create(_path=None):
            return None

    monkeypatch.setattr(graph_sync, "ConfigurationProvider", _ConfigurationProvider)
    monkeypatch.setattr(graph_sync, "KgBuildApplicationContainer", object())
    monkeypatch.setattr(graph_sync, "ProfileImportProtocol", object())


def test_graph_sync_help_from_root_cli() -> None:
    runner = CliRunner()
    result = runner.invoke(root_cli.app, ["graph", "sync", "run", "--help"])
    output = _normalize_cli_output(result.output)

    assert result.exit_code == 0
    assert "--debug" in output


def test_graph_sync_uses_default_profile_when_not_specified(monkeypatch) -> None:
    seen = {}

    def _stub_run(options):
        seen["options"] = options

    monkeypatch.setattr(graph_cmd, "run_graph_sync", _stub_run)
    monkeypatch.setattr(graph_cmd, "resolve_config_path", lambda _path: Path("worai.toml"))
    runner = CliRunner()
    result = runner.invoke(graph_cmd.app, ["sync", "run"])

    assert result.exit_code == 0
    assert seen["options"].profile == "default"


def test_graph_sync_command_calls_core(monkeypatch, tmp_path: Path) -> None:
    seen = {}

    def _stub_run(options):
        seen["options"] = options

    monkeypatch.setattr(graph_cmd, "run_graph_sync", _stub_run)
    monkeypatch.setattr(graph_cmd, "resolve_config_path", lambda _path: Path("worai.toml"))
    runner = CliRunner()
    result = runner.invoke(graph_cmd.app, ["sync", "run", "--debug"], obj={"profile": "zrh"})

    assert result.exit_code == 0
    assert seen["options"].profile == "zrh"
    assert seen["options"].config_path == Path("worai.toml")
    assert seen["options"].debug is True


def test_graph_sync_uses_root_config_option(monkeypatch, tmp_path: Path) -> None:
    seen = {}

    def _stub_run(options):
        seen["options"] = options

    monkeypatch.setattr(graph_cmd, "run_graph_sync", _stub_run)
    runner = CliRunner()
    cfg = tmp_path / "custom.toml"
    cfg.write_text("[profile.zrh]\napi_key='wl'\n", encoding="utf-8")
    result = runner.invoke(
        root_cli.app,
        ["--config", str(cfg), "--profile", "zrh", "graph", "sync", "run"],
    )

    assert result.exit_code == 0
    assert seen["options"].config_path == cfg


def test_graph_sync_uses_root_profile_option(monkeypatch, tmp_path: Path) -> None:
    seen = {}

    def _stub_run(options):
        seen["options"] = options

    monkeypatch.setattr(graph_cmd, "run_graph_sync", _stub_run)
    cfg = tmp_path / "custom.toml"
    cfg.write_text("[profiles.acme]\napi_key='wl'\n", encoding="utf-8")
    result = CliRunner().invoke(
        root_cli.app,
        ["--config", str(cfg), "--profile", "acme", "graph", "sync", "run"],
    )

    assert result.exit_code == 0
    assert seen["options"].profile == "acme"
    assert seen["options"].config_path == cfg


def test_graph_sync_help_lists_subcommands() -> None:
    runner = CliRunner()
    result = runner.invoke(root_cli.app, ["graph", "sync", "--help"])
    output = _normalize_cli_output(result.output)

    assert result.exit_code == 0
    assert "run" in output
    assert "create" in output


def test_graph_sync_create_command_calls_core(monkeypatch) -> None:
    seen = {}

    def _stub_create(options):
        seen["options"] = options

    monkeypatch.setattr(graph_cmd, "run_graph_sync_create", _stub_create)
    runner = CliRunner()
    result = runner.invoke(
        graph_cmd.app,
        [
            "sync",
            "create",
            "./demo-project",
            "--template",
            "./template",
            "--defaults",
            "--non-interactive",
            "--force",
        ],
    )

    assert result.exit_code == 0
    assert seen["options"].destination == Path("./demo-project")
    assert seen["options"].template == "./template"
    assert seen["options"].defaults is True
    assert seen["options"].non_interactive is True
    assert seen["options"].force is True


def test_graph_sync_create_requires_destination_in_non_interactive() -> None:
    runner = CliRunner()
    result = runner.invoke(graph_cmd.app, ["sync", "create", "--non-interactive"])

    assert result.exit_code == 1
    assert isinstance(result.exception, UsageError)
    assert "Destination path is required" in str(result.exception)


def test_load_service_account_json_supports_file_or_inline(tmp_path: Path) -> None:
    service_account = '{"type":"service_account"}'
    path = tmp_path / "service-account.json"
    path.write_text(service_account, encoding="utf-8")

    assert graph_sync.load_service_account_json(str(path)) == service_account
    assert graph_sync.load_service_account_json(service_account) == service_account


def test_load_service_account_json_rejects_invalid_inputs() -> None:
    try:
        graph_sync.load_service_account_json("not-json")
        assert False, "Expected invalid JSON/path error"
    except ValueError as exc:
        assert "json content or a file path" in str(exc).lower()

    try:
        graph_sync.load_service_account_json("[]")
        assert False, "Expected non-object JSON error"
    except ValueError as exc:
        assert "must be an object" in str(exc).lower()


def test_parse_bool_and_timeout_guardrails() -> None:
    assert graph_sync._parse_bool("yes", name="x") is True
    assert graph_sync._parse_bool("off", name="x") is False
    try:
        graph_sync._parse_bool("maybe", name="x")
        assert False, "Expected invalid bool"
    except ValueError:
        pass

    for bad in ["", object(), 0]:
        try:
            graph_sync._resolve_web_page_import_timeout_ms(bad)
            assert False, "Expected invalid timeout"
        except ValueError:
            pass


def test_shacl_validation_settings_mapping_legacy_and_new() -> None:
    assert graph_sync._extract_shacl_validation_settings({"shacl_validate_mode": "fail"}) == {
        "SHACL_VALIDATE_MODE": "fail"
    }
    assert graph_sync._extract_shacl_validation_settings({"shacl_validate_mode": "warn"}) == {
        "SHACL_VALIDATE_MODE": "warn"
    }
    assert graph_sync._extract_shacl_validation_settings({"shacl_validate_mode": "off"}) == {
        "SHACL_VALIDATE_MODE": "off"
    }
    assert graph_sync._extract_shacl_validation_settings(
        {
            "shacl_builtin_shapes": ["google-required"],
            "shacl_exclude_builtin_shapes": ["schemaorg-grammar"],
            "shacl_extra_shapes": ["custom.ttl"],
        }
    ) == {
        "SHACL_BUILTIN_SHAPES": ["google-required"],
        "SHACL_EXCLUDE_BUILTIN_SHAPES": ["schemaorg-grammar"],
        "SHACL_EXTRA_SHAPES": ["custom.ttl"],
    }


def test_shacl_validation_settings_reject_invalid_values() -> None:
    try:
        graph_sync._extract_shacl_validation_settings({"shacl_validate_mode": "strict"})
        assert False, "Expected invalid shacl_validate_mode value"
    except ValueError as exc:
        assert "warn, fail, off" in str(exc)

    try:
        graph_sync._extract_shacl_validation_settings({"shacl_validate_sync": "true"})
        assert False, "Expected unsupported shacl_validate_sync key"
    except ValueError as exc:
        assert "no longer supported" in str(exc)

    try:
        graph_sync._extract_shacl_validation_settings({"shacl_shape_specs": ["legacy.ttl"]})
        assert False, "Expected unsupported shacl_shape_specs key"
    except ValueError as exc:
        assert "no longer supported" in str(exc)

    try:
        graph_sync._extract_shacl_validation_settings({"shacl_builtin_shapes": [7]})
        assert False, "Expected invalid shape list value"
    except ValueError as exc:
        assert "entries must be strings" in str(exc).lower()


def test_build_dynamic_settings_supports_sitemap_source() -> None:
    lines, sheets_json = graph_sync._build_dynamic_settings(
        "wl_key",
        {
            "sitemap_url": "https://example.com/sitemap.xml",
            "sitemap_url_pattern": "example\\.com",
            "concurrency": 3,
        },
        google_search_console=False,
    )

    text = "\n".join(lines)
    assert "SITEMAP_URL" in text
    assert "SITEMAP_URL_PATTERN" in text
    assert "INGEST_SOURCE = 'sitemap'" in text
    assert "INGEST_LOADER = 'web_scrape_api'" in text
    assert "INGEST_TIMEOUT_MS" not in text
    assert "PLAYWRIGHT_WAIT_UNTIL" not in text
    assert sheets_json is None


def test_build_dynamic_settings_emits_new_shacl_validation_keys() -> None:
    lines, _ = graph_sync._build_dynamic_settings(
        "wl_key",
        {
            "urls": ["https://example.com/a"],
            "shacl_validate_mode": "fail",
            "shacl_builtin_shapes": ["google-required"],
            "shacl_exclude_builtin_shapes": ["schemaorg-grammar"],
            "shacl_extra_shapes": ["custom-shape.ttl"],
        },
        google_search_console=False,
    )

    text = "\n".join(lines)
    assert "SHACL_VALIDATE_SYNC" not in text
    assert "SHACL_SHAPE_SPECS" not in text
    assert "SHACL_VALIDATE_MODE = 'fail'" in text
    assert "SHACL_BUILTIN_SHAPES = ['google-required']" in text
    assert "SHACL_EXCLUDE_BUILTIN_SHAPES = ['schemaorg-grammar']" in text
    assert "SHACL_EXTRA_SHAPES = ['custom-shape.ttl']" in text


def test_build_dynamic_settings_does_not_require_sheets_account_for_urls() -> None:
    lines, sheets_json = graph_sync._build_dynamic_settings(
        "wl_key",
        {
            "urls": ["https://example.com/a"],
            "sheets_service_account": "not-json-and-not-file",
        },
        google_search_console=False,
    )

    text = "\n".join(lines)
    assert "URLS" in text
    assert "INGEST_SOURCE = 'urls'" in text
    assert sheets_json is None


def test_build_dynamic_settings_prefers_new_ingest_loader_over_legacy() -> None:
    lines, _ = graph_sync._build_dynamic_settings(
        "wl_key",
        {
            "urls": ["https://example.com/a"],
            "web_page_import_mode": "proxy",
            "ingest_loader": "web_scrape_api",
        },
        google_search_console=False,
    )

    assert "INGEST_LOADER = 'web_scrape_api'" in lines
    assert "INGEST_LOADER = 'web_scrape_api'" in lines


def test_build_dynamic_settings_maps_premium_scraper_loader_to_matching_fetch_mode() -> None:
    lines, _ = graph_sync._build_dynamic_settings(
        "wl_key",
        {
            "urls": ["https://example.com/a"],
            "ingest_loader": "premium_scraper",
        },
        google_search_console=False,
    )

    assert "INGEST_LOADER = 'premium_scraper'" in lines
    assert "INGEST_LOADER = 'premium_scraper'" in lines


def test_build_dynamic_settings_maps_proxy_loader_to_matching_fetch_mode() -> None:
    lines, _ = graph_sync._build_dynamic_settings(
        "wl_key",
        {
            "urls": ["https://example.com/a"],
            "ingest_loader": "proxy",
        },
        google_search_console=False,
    )

    assert "INGEST_LOADER = 'proxy'" in lines
    assert "INGEST_LOADER = 'proxy'" in lines


def test_build_dynamic_settings_legacy_web_page_import_mode_still_supported() -> None:
    lines, _ = graph_sync._build_dynamic_settings(
        "wl_key",
        {
            "urls": ["https://example.com/a"],
            "web_page_import_mode": "premium_scraper",
        },
        google_search_console=False,
    )

    assert "INGEST_LOADER = 'premium_scraper'" in lines
    assert "INGEST_LOADER = 'premium_scraper'" in lines


def test_build_dynamic_settings_converts_timeout_seconds_to_ms() -> None:
    lines, _ = graph_sync._build_dynamic_settings(
        "wl_key",
        {
            "urls": ["https://example.com/a"],
            "web_page_import_timeout": 30,
        },
        google_search_console=False,
    )

    assert any(line == "INGEST_TIMEOUT_MS = 30000" for line in lines)


def test_build_dynamic_settings_prefers_modern_ingest_timeout_and_wait_until() -> None:
    lines, _ = graph_sync._build_dynamic_settings(
        "wl_key",
        {
            "urls": ["https://example.com/a"],
            "ingest_timeout_ms": 9000,
            "playwright_wait_until": "load",
            "web_page_import_timeout": 60,
        },
        google_search_console=False,
    )

    assert any(line == "INGEST_TIMEOUT_MS = 9000" for line in lines)
    assert any(line == "PLAYWRIGHT_WAIT_UNTIL = 'load'" for line in lines)


def test_build_dynamic_settings_accepts_timeout_suffixes() -> None:
    lines_seconds, _ = graph_sync._build_dynamic_settings(
        "wl_key",
        {
            "urls": ["https://example.com/a"],
            "web_page_import_timeout": "2.5s",
        },
        google_search_console=False,
    )
    lines_millis, _ = graph_sync._build_dynamic_settings(
        "wl_key",
        {
            "urls": ["https://example.com/a"],
            "web_page_import_timeout": "2500ms",
        },
        google_search_console=False,
    )

    assert any(line == "INGEST_TIMEOUT_MS = 2500" for line in lines_seconds)
    assert any(line == "INGEST_TIMEOUT_MS = 2500" for line in lines_millis)


def test_build_dynamic_settings_treats_plain_numeric_timeout_as_seconds() -> None:
    lines, _ = graph_sync._build_dynamic_settings(
        "wl_key",
        {
            "urls": ["https://example.com/a"],
            "web_page_import_timeout": 60000,
        },
        google_search_console=False,
    )

    assert any(line == "INGEST_TIMEOUT_MS = 60000000" for line in lines)


def test_build_dynamic_settings_rejects_invalid_timeout() -> None:
    try:
        graph_sync._build_dynamic_settings(
            "wl_key",
            {
                "urls": ["https://example.com/a"],
                "web_page_import_timeout": "abc",
            },
            google_search_console=False,
        )
        assert False, "Expected ValueError for invalid timeout format"
    except ValueError as exc:
        assert "web_page_import_timeout" in str(exc)


def test_build_dynamic_settings_rejects_invalid_playwright_wait_until() -> None:
    try:
        graph_sync._build_dynamic_settings(
            "wl_key",
            {
                "urls": ["https://example.com/a"],
                "playwright_wait_until": "interactive",
            },
            google_search_console=False,
        )
        assert False, "Expected ValueError for invalid wait-until"
    except ValueError as exc:
        assert "playwright_wait_until" in str(exc)


def test_build_dynamic_settings_requires_sheets_account_for_sheets_source() -> None:
    try:
        graph_sync._build_dynamic_settings(
            "wl_key",
            {
                "sheets_url": "https://docs.google.com/spreadsheets/d/abc",
                "sheets_name": "Sheet1",
            },
            google_search_console=False,
        )
        assert False, "Expected ValueError for missing sheets_service_account"
    except ValueError as exc:
        assert "sheets_service_account" in str(exc)


def test_build_dynamic_settings_requires_both_sheets_fields() -> None:
    try:
        graph_sync._build_dynamic_settings(
            "wl_key",
            {
                "sheets_url": "https://docs.google.com/spreadsheets/d/abc",
                "sheets_service_account": "{\"type\":\"service_account\"}",
            },
            google_search_console=False,
        )
        assert False, "Expected ValueError for missing sheets_name"
    except ValueError as exc:
        assert "both sheets_url and sheets_name" in str(exc).lower()


def test_build_dynamic_settings_auto_source_resolves_to_sheets() -> None:
    lines, _ = graph_sync._build_dynamic_settings(
        "wl_key",
        {
            "ingest_source": "auto",
            "sheets_url": "https://docs.google.com/spreadsheets/d/abc",
            "sheets_name": "Sheet1",
            "sheets_service_account": "{\"type\":\"service_account\"}",
        },
        google_search_console=False,
    )
    assert "INGEST_SOURCE = 'sheets'" in lines


def test_build_dynamic_settings_rejects_multiple_sources() -> None:
    try:
        graph_sync._build_dynamic_settings(
            "wl_key",
            {
                "urls": ["https://example.com/a"],
                "sitemap_url": "https://example.com/sitemap.xml",
            },
            google_search_console=False,
        )
        assert False, "Expected ValueError for multiple source modes"
    except ValueError as exc:
        assert "exactly one source" in str(exc).lower()


def test_resolve_google_search_console_default_false(tmp_path: Path) -> None:
    cfg = tmp_path / "worai.toml"
    cfg.write_text("[profile.acme]\napi_key='wl'\n", encoding="utf-8")

    assert graph_sync.resolve_google_search_console(cfg, "acme") is False


def test_resolve_google_search_console_global_and_profile_override(tmp_path: Path) -> None:
    cfg = tmp_path / "worai.toml"
    cfg.write_text(
        "google_search_console = false\n\n[profile.acme]\napi_key='wl'\ngoogle_search_console = true\n",
        encoding="utf-8",
    )

    assert graph_sync.resolve_google_search_console(cfg, "acme") is True


def test_resolve_google_search_console_supports_profiles_section(tmp_path: Path) -> None:
    cfg = tmp_path / "worai.toml"
    cfg.write_text(
        "google_search_console = false\n\n[profiles.acme]\napi_key='wl'\ngoogle_search_console = true\n",
        encoding="utf-8",
    )

    assert graph_sync.resolve_google_search_console(cfg, "acme") is True


def test_resolve_google_search_console_file_errors(tmp_path: Path) -> None:
    assert graph_sync.resolve_google_search_console(tmp_path / "missing.toml", "acme") is False
    bad = tmp_path / "bad.toml"
    bad.write_text("not = [valid", encoding="utf-8")
    try:
        graph_sync.resolve_google_search_console(bad, "acme")
        assert False, "Expected invalid TOML error"
    except ValueError as exc:
        assert "invalid toml config" in str(exc).lower()


def test_resolve_postprocessor_runtime_default_none(tmp_path: Path) -> None:
    cfg = tmp_path / "worai.toml"
    cfg.write_text("[profile.acme]\napi_key='wl'\n", encoding="utf-8")

    assert graph_sync.resolve_postprocessor_runtime(cfg, "acme") is None


def test_resolve_postprocessor_runtime_global_and_profile_override(tmp_path: Path) -> None:
    cfg = tmp_path / "worai.toml"
    cfg.write_text(
        "postprocessor_runtime = 'oneshot'\n\n"
        "[profile.acme]\napi_key='wl'\npostprocessor_runtime = 'persistent'\n",
        encoding="utf-8",
    )

    assert graph_sync.resolve_postprocessor_runtime(cfg, "acme") == "persistent"


def test_resolve_postprocessor_runtime_supports_profiles_section(tmp_path: Path) -> None:
    cfg = tmp_path / "worai.toml"
    cfg.write_text(
        "postprocessor_runtime = 'oneshot'\n\n"
        "[profiles.acme]\napi_key='wl'\npostprocessor_runtime = 'persistent'\n",
        encoding="utf-8",
    )

    assert graph_sync.resolve_postprocessor_runtime(cfg, "acme") == "persistent"


def test_resolve_postprocessor_runtime_validation_errors(tmp_path: Path) -> None:
    assert graph_sync.resolve_postprocessor_runtime(tmp_path / "missing.toml", "acme") is None
    bad = tmp_path / "bad.toml"
    bad.write_text("not = [valid", encoding="utf-8")
    try:
        graph_sync.resolve_postprocessor_runtime(bad, "acme")
        assert False, "Expected invalid TOML error"
    except ValueError as exc:
        assert "invalid toml config" in str(exc).lower()

    cfg = tmp_path / "invalid-runtime.toml"
    cfg.write_text("[profile.acme]\napi_key='wl'\npostprocessor_runtime = 7\n", encoding="utf-8")
    try:
        graph_sync.resolve_postprocessor_runtime(cfg, "acme")
        assert False, "Expected type validation error"
    except ValueError as exc:
        assert "must be a string" in str(exc).lower()

    cfg.write_text("[profile.acme]\napi_key='wl'\npostprocessor_runtime = 'invalid'\n", encoding="utf-8")
    try:
        graph_sync.resolve_postprocessor_runtime(cfg, "acme")
        assert False, "Expected enum validation error"
    except ValueError as exc:
        assert "oneshot, persistent" in str(exc).lower()


def test_parse_settings_and_validation_formatters() -> None:
    parsed = graph_sync._parse_settings_lines(["A = 1", "ignored-line"])
    assert parsed == {"A": 1}
    assert graph_sync._format_validation_summary(None) == "validation=disabled"
    assert graph_sync._format_validation_summary("x") == "validation=x"
    text = graph_sync._format_validation_summary(
        {
            "total": 2,
            "pass": 1,
            "fail": 1,
            "warnings": {"count": 3},
            "errors": {"count": 4},
        }
    )
    assert "warnings=3" in text
    assert "errors=4" in text


def test_cloud_progress_and_kpi_non_dict_payloads_are_logged(caplog) -> None:
    caplog.set_level(logging.INFO, logger="worai.graph")
    graph_sync._on_cloud_progress("abc")
    graph_sync._on_cloud_kpi("xyz")
    assert "Graph sync progress: abc" in caplog.text
    assert "Graph sync KPI: xyz" in caplog.text


def test_cloud_kpi_invariant_mismatch_warns(caplog) -> None:
    caplog.set_level(logging.WARNING, logger="worai.graph")
    graph_sync._on_cloud_kpi(
        {
            "profile": "zrh",
            "run_id": "r",
            "totals": {},
            "validation": {"total": 3, "pass": 1, "fail": 1},
        }
    )
    assert "KPI validation invariant mismatch" in caplog.text


def test_build_cloud_workflow_config_errors(monkeypatch) -> None:
    class _CloudWorkflowConfig:
        def __init__(self, **_kwargs):
            raise TypeError("boom")

    monkeypatch.setattr(graph_sync, "CloudWorkflowConfig", None)
    try:
        graph_sync._build_cloud_workflow_config(
            lines=["WORDLIFT_KEY = 'wl'", "URLS = ['https://example.com']"],
            sheets_service_account_json=None,
            debug=False,
            debug_profile_name="p",
        )
        assert False, "Expected missing CloudWorkflowConfig error"
    except ValueError as exc:
        assert "cloud workflow config is unavailable" in str(exc).lower()

    monkeypatch.setattr(graph_sync, "CloudWorkflowConfig", _CloudWorkflowConfig)
    try:
        graph_sync._build_cloud_workflow_config(
            lines=["WORDLIFT_KEY = 'wl'"],
            sheets_service_account_json=None,
            debug=False,
            debug_profile_name="p",
        )
        assert False, "Expected source mode error"
    except ValueError as exc:
        assert "exactly one source mode" in str(exc).lower()

    try:
        graph_sync._build_cloud_workflow_config(
            lines=[
                "WORDLIFT_KEY = 'wl'",
                "SHEETS_URL = 'https://docs.google.com/spreadsheets/d/abc'",
            ],
            sheets_service_account_json=None,
            debug=False,
            debug_profile_name="p",
        )
        assert False, "Expected missing sheets_name error"
    except ValueError as exc:
        assert "sheets_url and sheets_name" in str(exc).lower()

    try:
        graph_sync._build_cloud_workflow_config(
            lines=["WORDLIFT_KEY = 'wl'", "URLS = ['https://example.com']"],
            sheets_service_account_json=None,
            debug=False,
            debug_profile_name="p",
        )
        assert False, "Expected sdk contract type error"
    except ValueError as exc:
        assert "expected 6.x cloud workflow contract" in str(exc).lower()


def test_run_cloud_workflow_with_callbacks_support_checks(monkeypatch) -> None:
    async def _protocol_factory(_context, **_kwargs):
        return object()

    monkeypatch.setattr(graph_sync, "ConfigurationProvider", None)
    monkeypatch.setattr(graph_sync, "KgBuildApplicationContainer", None)
    monkeypatch.setattr(graph_sync, "run_cloud_workflow", None)

    try:
        import asyncio

        asyncio.run(
            graph_sync._run_cloud_workflow_with_callbacks(
                lines=["WORDLIFT_KEY = 'wl'", "URLS = ['https://example.com']"],
                sheets_service_account_json=None,
                debug=False,
                debug_profile_name="p",
                protocol_factory=_protocol_factory,
            )
        )
        assert False, "Expected cloud workflow support error"
    except ValueError as exc:
        assert "cloud workflow support is unavailable" in str(exc).lower()


def test_run_graph_sync_async_keeps_cwd_root_and_passes_protocol(monkeypatch) -> None:
    profile = _FakeProfile(
        name="zrh",
        api_key="wl_key",
        settings={
            "overwrite": True,
            "concurrency": 3,
            "web_page_import_mode": "premium_scraper",
            "web_page_import_timeout": 1234,
            "urls": ["https://example.com/a"],
            "custom_toggle": "on",
        },
    )
    captured = {}

    class _ProfileMap(dict):
        def get(self, key):
            return profile if key == "zrh" else None

    class _FakeProtocol:
        def __init__(self, context, profile, root_dir, debug_dir, on_progress=None):
            self.context = context
            self.profile = profile
            self.root_dir = root_dir
            self.debug_dir = debug_dir
            self.on_progress = on_progress

    class _ConfigurationProvider:
        @staticmethod
        def create(_path=None):
            return None

    class _FakeContainer:
        def __init__(self, _provider):
            self.provider = _provider

    class _CloudWorkflowConfig:
        def __init__(self, **kwargs):
            self.kwargs = kwargs

    async def _stub_run_cloud_workflow(
        *,
        config,
        configuration_provider_create,
        container_factory,
        protocol_factory,
        on_info=None,
        on_progress=None,
        on_kpi=None,
    ):
        captured["config"] = config.kwargs
        captured["postprocessor_runtime"] = graph_sync.os.environ.get("POSTPROCESSOR_RUNTIME")
        _provider = configuration_provider_create("dynamic.py")
        _container = container_factory(_provider)
        protocol = protocol_factory(context={"k": "v"}, debug_dir=Path("dbg"), workflow_config=config)
        if hasattr(protocol, "__await__"):
            protocol = await protocol
        captured["protocol"] = protocol

    monkeypatch.setattr(graph_sync, "ConfigurationProvider", _ConfigurationProvider)
    monkeypatch.setattr(graph_sync, "KgBuildApplicationContainer", _FakeContainer)
    monkeypatch.setattr(graph_sync, "load_profile_config", lambda _path: _ProfileMap())
    monkeypatch.setattr(graph_sync, "ProfileImportProtocol", _FakeProtocol)
    monkeypatch.setattr(graph_sync, "CloudWorkflowConfig", _CloudWorkflowConfig)
    monkeypatch.setattr(graph_sync, "run_cloud_workflow", _stub_run_cloud_workflow)
    monkeypatch.setattr(graph_sync, "resolve_google_search_console", lambda *_args, **_kwargs: True)
    monkeypatch.setattr(graph_sync, "resolve_postprocessor_runtime", lambda *_args, **_kwargs: "persistent")
    monkeypatch.setenv("POSTPROCESSOR_RUNTIME", "oneshot")

    graph_sync.run_graph_sync(
        graph_sync.GraphSyncOptions(
            profile="zrh",
            config_path=Path("worai.toml"),
            debug=True,
        )
    )

    assert captured["config"]["wordlift_key"] == "wl_key"
    assert captured["config"]["urls"] == ["https://example.com/a"]
    assert captured["config"]["ingest_loader"] == "premium_scraper"
    assert captured["config"]["ingest_timeout_ms"] == 1234000
    assert "playwright_wait_until" not in captured["config"]
    assert "web_page_import_mode" not in captured["config"]
    assert "web_page_import_timeout" not in captured["config"]
    assert captured["config"]["extra_settings"]["CUSTOM_TOGGLE"] == "on"
    assert captured["config"]["extra_settings"]["GOOGLE_SEARCH_CONSOLE"] is True
    assert captured["postprocessor_runtime"] == "persistent"
    assert captured["config"]["debug"] is True
    assert captured["config"]["debug_profile_name"] == "zrh"
    assert captured["protocol"].root_dir == Path.cwd()
    assert graph_sync.os.environ.get("POSTPROCESSOR_RUNTIME") == "oneshot"


def test_run_graph_sync_async_uses_cloud_flow_callbacks_when_available(monkeypatch, caplog) -> None:
    profile = _FakeProfile(
        name="zrh",
        api_key="wl_key",
        settings={
            "urls": ["https://example.com/a"],
            "overwrite": True,
            "concurrency": 2,
        },
    )
    captured = {}

    class _ProfileMap(dict):
        def get(self, key):
            return profile if key == "zrh" else None

    class _ConfigurationProvider:
        @staticmethod
        def create(path=None):
            captured["provider_path"] = path
            return {"provider_path": path}

    class _FakeContainer:
        def __init__(self, provider):
            self.provider = provider

    class _FakeProtocol:
        def __init__(self, context, profile, root_dir, debug_dir, on_progress=None):
            self.context = context
            self.profile = profile
            self.root_dir = root_dir
            self.debug_dir = debug_dir
            self.on_progress = on_progress

    class _CloudWorkflowConfig:
        def __init__(self, **kwargs):
            self.kwargs = kwargs

    async def _stub_run_cloud_workflow(
        *,
        config,
        configuration_provider_create,
        container_factory,
        protocol_factory,
        on_info=None,
        on_progress=None,
        on_kpi=None,
    ):
        captured["config"] = config.kwargs
        provider = configuration_provider_create("dynamic.py")
        container = container_factory(provider)
        protocol = protocol_factory(context={"k": "v"}, debug_dir=Path("dbg"), workflow_config=config)
        if hasattr(protocol, "__await__"):
            protocol = await protocol
        captured["container"] = container
        captured["protocol"] = protocol
        if on_info:
            on_info("cloud-info")
        if on_progress:
            on_progress(
                {
                    "kind": "graph",
                    "profile": "zrh",
                    "url": "https://example.com/a",
                    "graph": {"entities": 1, "type_assertions": 2, "property_assertions": 3},
                    "validation": None,
                }
            )
        if on_kpi:
            on_kpi(
                {
                    "run_id": "2026-02-24T15:12:03Z",
                    "profile": "zrh",
                    "totals": {
                        "total_entities": 1,
                        "type_assertions_total": 2,
                        "property_assertions_total": 3,
                    },
                    "entities_by_type": {"https://schema.org/WebPage": 1},
                    "properties_by_predicate": {"https://schema.org/name": 1},
                    "validation": None,
                }
            )

    monkeypatch.setattr(graph_sync, "ConfigurationProvider", _ConfigurationProvider)
    monkeypatch.setattr(graph_sync, "KgBuildApplicationContainer", _FakeContainer)
    monkeypatch.setattr(graph_sync, "ProfileImportProtocol", _FakeProtocol)
    monkeypatch.setattr(graph_sync, "CloudWorkflowConfig", _CloudWorkflowConfig)
    monkeypatch.setattr(graph_sync, "run_cloud_workflow", _stub_run_cloud_workflow)
    monkeypatch.setattr(graph_sync, "load_profile_config", lambda _path: _ProfileMap())
    monkeypatch.setattr(graph_sync, "resolve_google_search_console", lambda *_args, **_kwargs: False)
    monkeypatch.setattr(graph_sync, "resolve_postprocessor_runtime", lambda *_args, **_kwargs: None)

    caplog.set_level(logging.INFO, logger="worai.graph")
    graph_sync.run_graph_sync(
        graph_sync.GraphSyncOptions(
            profile="zrh",
            config_path=Path("worai.toml"),
            debug=True,
        )
    )

    assert captured["config"]["wordlift_key"] == "wl_key"
    assert captured["config"]["urls"] == ["https://example.com/a"]
    assert captured["config"]["debug"] is True
    assert captured["config"]["debug_profile_name"] == "zrh"
    assert captured["protocol"].root_dir == Path.cwd()
    assert "cloud-info" in caplog.text
    assert "Progress[graph] https://example.com/a" in caplog.text
    assert "KPI profile=zrh run_id=2026-02-24T15:12:03Z" in caplog.text


def test_run_graph_sync_debug_writes_callback_and_static_artifacts(monkeypatch, tmp_path: Path) -> None:
    profile = _FakeProfile(
        name="zrh",
        api_key="wl_key",
        settings={"urls": ["https://example.com/a"]},
    )
    callback_url = "https://example.com/a"

    class _ProfileMap(dict):
        def get(self, key):
            return profile if key == "zrh" else None

    class _ConfigurationProvider:
        @staticmethod
        def create(_path=None):
            return None

    class _FakeContainer:
        def __init__(self, _provider):
            self.provider = _provider

    class _CloudWorkflowConfig:
        def __init__(self, **kwargs):
            self.kwargs = kwargs

    class _FakeProtocol:
        def __init__(self, context, profile, root_dir, debug_dir, on_progress=None):
            self.context = context
            self.profile = profile
            self.root_dir = root_dir
            self.debug_dir = debug_dir
            self.on_progress = on_progress
            if self.debug_dir:
                self.debug_dir.mkdir(parents=True, exist_ok=True)
                (self.debug_dir / "static_templates.ttl").write_text(
                    "<s> <p> <o> .\n",
                    encoding="utf-8",
                )
                digest = hashlib.sha256(callback_url.encode("utf-8")).hexdigest()
                (self.debug_dir / f"cloud_{digest}.ttl").write_text(
                    "<s2> <p> <o2> .\n",
                    encoding="utf-8",
                )
                if callable(self.on_progress):
                    self.on_progress({"kind": "static_templates", "profile": profile.name, "graph": {}})
                    self.on_progress(
                        {
                            "kind": "graph",
                            "profile": profile.name,
                            "url": callback_url,
                            "graph": {},
                        }
                    )

    async def _stub_run_cloud_workflow(
        *,
        config,
        configuration_provider_create,
        container_factory,
        protocol_factory,
        on_info=None,
        on_progress=None,
        on_kpi=None,
    ):
        _provider = configuration_provider_create("dynamic.py")
        _container = container_factory(_provider)
        debug_dir = None
        if config.kwargs.get("debug"):
            debug_dir = tmp_path / "output" / "debug_cloud" / config.kwargs["debug_profile_name"]
        try:
            protocol = protocol_factory(
                context={"k": "v"},
                debug_dir=debug_dir,
                workflow_config=config,
                on_progress=on_progress,
            )
        except TypeError:
            protocol = protocol_factory(context={"k": "v"})
        if hasattr(protocol, "__await__"):
            await protocol
        if callable(on_info):
            on_info("done")
        if callable(on_kpi):
            on_kpi({"profile": "zrh", "run_id": "r", "totals": {}, "validation": None})

    monkeypatch.setattr(graph_sync, "ConfigurationProvider", _ConfigurationProvider)
    monkeypatch.setattr(graph_sync, "KgBuildApplicationContainer", _FakeContainer)
    monkeypatch.setattr(graph_sync, "ProfileImportProtocol", _FakeProtocol)
    monkeypatch.setattr(graph_sync, "CloudWorkflowConfig", _CloudWorkflowConfig)
    monkeypatch.setattr(graph_sync, "run_cloud_workflow", _stub_run_cloud_workflow)
    monkeypatch.setattr(graph_sync, "load_profile_config", lambda _path: _ProfileMap())
    monkeypatch.setattr(graph_sync, "resolve_google_search_console", lambda *_args, **_kwargs: False)
    monkeypatch.setattr(graph_sync, "resolve_postprocessor_runtime", lambda *_args, **_kwargs: None)

    graph_sync.run_graph_sync(
        graph_sync.GraphSyncOptions(profile="zrh", config_path=Path("worai.toml"), debug=True)
    )

    debug_dir = tmp_path / "output" / "debug_cloud" / "zrh"
    assert (debug_dir / "static_templates.ttl").exists()
    digest = hashlib.sha256(callback_url.encode("utf-8")).hexdigest()
    assert (debug_dir / f"cloud_{digest}.ttl").exists()


def test_run_graph_sync_without_debug_does_not_write_artifacts(monkeypatch, tmp_path: Path) -> None:
    profile = _FakeProfile(
        name="zrh",
        api_key="wl_key",
        settings={"urls": ["https://example.com/a"]},
    )

    class _ProfileMap(dict):
        def get(self, key):
            return profile if key == "zrh" else None

    class _ConfigurationProvider:
        @staticmethod
        def create(_path=None):
            return None

    class _FakeContainer:
        def __init__(self, _provider):
            self.provider = _provider

    class _CloudWorkflowConfig:
        def __init__(self, **kwargs):
            self.kwargs = kwargs

    class _FakeProtocol:
        def __init__(self, context, profile, root_dir, debug_dir, on_progress=None):
            self.context = context
            self.profile = profile
            self.root_dir = root_dir
            self.debug_dir = debug_dir
            self.on_progress = on_progress
            if self.debug_dir:
                self.debug_dir.mkdir(parents=True, exist_ok=True)
                (self.debug_dir / "static_templates.ttl").write_text("x", encoding="utf-8")

    async def _stub_run_cloud_workflow(
        *,
        config,
        configuration_provider_create,
        container_factory,
        protocol_factory,
        on_info=None,
        on_progress=None,
        on_kpi=None,
    ):
        _provider = configuration_provider_create("dynamic.py")
        _container = container_factory(_provider)
        debug_dir = None
        if config.kwargs.get("debug"):
            debug_dir = tmp_path / "output" / "debug_cloud" / config.kwargs["debug_profile_name"]
        try:
            protocol = protocol_factory(
                context={"k": "v"},
                debug_dir=debug_dir,
                workflow_config=config,
                on_progress=on_progress,
            )
        except TypeError:
            protocol = protocol_factory(context={"k": "v"})
        if hasattr(protocol, "__await__"):
            await protocol
        if callable(on_info):
            on_info("done")
        if callable(on_kpi):
            on_kpi({"profile": "zrh", "run_id": "r", "totals": {}, "validation": None})

    monkeypatch.setattr(graph_sync, "ConfigurationProvider", _ConfigurationProvider)
    monkeypatch.setattr(graph_sync, "KgBuildApplicationContainer", _FakeContainer)
    monkeypatch.setattr(graph_sync, "ProfileImportProtocol", _FakeProtocol)
    monkeypatch.setattr(graph_sync, "CloudWorkflowConfig", _CloudWorkflowConfig)
    monkeypatch.setattr(graph_sync, "run_cloud_workflow", _stub_run_cloud_workflow)
    monkeypatch.setattr(graph_sync, "load_profile_config", lambda _path: _ProfileMap())
    monkeypatch.setattr(graph_sync, "resolve_google_search_console", lambda *_args, **_kwargs: False)
    monkeypatch.setattr(graph_sync, "resolve_postprocessor_runtime", lambda *_args, **_kwargs: None)

    graph_sync.run_graph_sync(
        graph_sync.GraphSyncOptions(profile="zrh", config_path=Path("worai.toml"), debug=False)
    )

    debug_dir = tmp_path / "output" / "debug_cloud" / "zrh"
    assert not debug_dir.exists()


def test_run_graph_sync_async_fails_without_cloud_flow_support(monkeypatch) -> None:
    profile = _FakeProfile(
        name="zrh",
        api_key="wl_key",
        settings={"urls": ["https://example.com/a"]},
    )

    class _ProfileMap(dict):
        def get(self, key):
            return profile if key == "zrh" else None

    monkeypatch.setattr(graph_sync, "ConfigurationProvider", None)
    monkeypatch.setattr(graph_sync, "KgBuildApplicationContainer", None)
    monkeypatch.setattr(graph_sync, "run_cloud_workflow", None)
    monkeypatch.setattr(graph_sync, "load_profile_config", lambda _path: _ProfileMap())
    monkeypatch.setattr(graph_sync, "resolve_google_search_console", lambda *_args, **_kwargs: False)
    monkeypatch.setattr(graph_sync, "resolve_postprocessor_runtime", lambda *_args, **_kwargs: None)

    try:
        graph_sync.run_graph_sync(
            graph_sync.GraphSyncOptions(
                profile="zrh",
                config_path=Path("worai.toml"),
                debug=False,
            )
        )
        assert False, "Expected ValueError when cloud workflow support is unavailable"
    except ValueError as exc:
        assert "unavailable" in str(exc).lower()


def test_run_graph_sync_async_fails_when_profile_has_no_api_key(monkeypatch) -> None:
    profile = _FakeProfile(name="zrh", api_key=None, settings={"urls": ["https://example.com"]})

    class _ProfileMap(dict):
        def get(self, key):
            return profile if key == "zrh" else None

    _patch_sdk_availability(monkeypatch)
    monkeypatch.setattr(graph_sync, "load_profile_config", lambda _path: _ProfileMap())

    runner = CliRunner()
    result = runner.invoke(graph_cmd.app, ["sync", "run"], obj={"profile": "zrh"})

    assert result.exit_code == 1
    assert isinstance(result.exception, UsageError)
    assert "missing api_key" in str(result.exception)


def test_graph_sync_create_builds_copier_command_with_unsafe_by_default(tmp_path: Path, monkeypatch) -> None:
    seen = {}

    def _stub_run(cmd, check):
        seen["cmd"] = cmd
        seen["check"] = check
        return None

    monkeypatch.setattr(graph_create.subprocess, "run", _stub_run)
    graph_create.run_graph_sync_create(
        graph_create.GraphSyncCreateOptions(
            destination=tmp_path / "new-project",
        )
    )

    assert seen["check"] is True
    assert seen["cmd"][:5] == [sys.executable, "-m", "copier", "copy", "--UNSAFE"]
    assert seen["cmd"][-2] == "gh:wordlift/graph-sync-template"
    assert seen["cmd"][-1] == str(tmp_path / "new-project")


def test_graph_sync_create_missing_copier_binary(tmp_path: Path, monkeypatch) -> None:
    def _stub_run(_cmd, check):
        raise FileNotFoundError("copier")

    monkeypatch.setattr(graph_create.subprocess, "run", _stub_run)

    try:
        graph_create.run_graph_sync_create(
            graph_create.GraphSyncCreateOptions(destination=tmp_path / "new-project")
        )
        assert False, "Expected UsageError for missing copier executable"
    except UsageError as exc:
        assert "uv sync --extra dev" in str(exc)


def test_graph_sync_create_non_interactive_missing_answers_surfaces_clear_error(
    tmp_path: Path, monkeypatch
) -> None:
    seen = {}

    def _stub_run(_cmd, check):
        seen["cmd"] = _cmd
        raise subprocess.CalledProcessError(returncode=2, cmd=["copier"])

    monkeypatch.setattr(graph_create.subprocess, "run", _stub_run)

    try:
        graph_create.run_graph_sync_create(
            graph_create.GraphSyncCreateOptions(
                destination=tmp_path / "new-project",
                non_interactive=True,
            )
        )
        assert False, "Expected UsageError for non-interactive missing answers"
    except UsageError as exc:
        assert "--non-interactive mode" in str(exc)
        assert "Secret question requires a default value" in str(exc)
        assert "exit code: 2" in str(exc)

    assert "--non-interactive" not in seen["cmd"]
    assert "--defaults" in seen["cmd"]


def test_graph_sync_create_template_override_pass_through(tmp_path: Path, monkeypatch) -> None:
    seen = {}

    def _stub_run(cmd, check):
        seen["cmd"] = cmd
        return None

    monkeypatch.setattr(graph_create.subprocess, "run", _stub_run)
    graph_create.run_graph_sync_create(
        graph_create.GraphSyncCreateOptions(
            destination=tmp_path / "new-project",
            template="./local-template",
        )
    )

    assert seen["cmd"][-2] == "./local-template"


def test_graph_sync_create_vcs_ref_pass_through(tmp_path: Path, monkeypatch) -> None:
    seen = {}

    def _stub_run(cmd, check):
        seen["cmd"] = cmd
        return None

    monkeypatch.setattr(graph_create.subprocess, "run", _stub_run)
    graph_create.run_graph_sync_create(
        graph_create.GraphSyncCreateOptions(
            destination=tmp_path / "new-project",
            vcs_ref="v1.2.3",
        )
    )

    assert "--vcs-ref" in seen["cmd"]
    idx = seen["cmd"].index("--vcs-ref")
    assert seen["cmd"][idx + 1] == "v1.2.3"


def test_graph_sync_create_rejects_non_empty_destination_without_force(tmp_path: Path) -> None:
    destination = tmp_path / "existing"
    destination.mkdir(parents=True)
    (destination / "file.txt").write_text("x", encoding="utf-8")

    try:
        graph_create.run_graph_sync_create(
            graph_create.GraphSyncCreateOptions(destination=destination)
        )
        assert False, "Expected UsageError for non-empty destination"
    except UsageError as exc:
        assert "Destination is not empty" in str(exc)
