from __future__ import annotations

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

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 "--profile" in output
    assert "--debug" in output


def test_graph_sync_requires_profile_option() -> None:
    runner = CliRunner()
    result = runner.invoke(graph_cmd.app, ["sync", "run"])
    output = _normalize_cli_output(result.output)

    assert result.exit_code == 2
    assert "--profile" in output


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", "--profile", "zrh", "--debug"])

    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), "graph", "sync", "run", "--profile", "zrh"],
    )

    assert result.exit_code == 0
    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_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 = 60000" in text
    assert sheets_json is None


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": 60,
        },
        google_search_console=False,
    )

    assert any(line == "INGEST_TIMEOUT_MS = 60000" 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_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):
            self.context = context
            self.profile = profile
            self.root_dir = root_dir
            self.debug_dir = debug_dir

    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 "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):
            self.context = context
            self.profile = profile
            self.root_dir = root_dir
            self.debug_dir = debug_dir

    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_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", "--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"][:3] == ["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)
