"""
Background polling loop for discovery and catalog refresh (TZ FR-1, FR-7, NFR-3, NFR-4).

Author: Vasiliy Zdanovskiy
email: vasilyvz@gmail.com

Purpose: Runs one full cycle (discover -> validate/filter -> build -> atomic swap)
and optionally a loop that invokes the cycle periodically in a background thread.
This module does not start the thread or expose API/tool context; the lifespan
module is responsible for starting the polling loop. All intervals come from config.
"""

from __future__ import annotations

import asyncio
import logging
import threading
from typing import Any, Dict, List, Optional, Protocol, Sequence, Union, cast

from mcp_proxy_adapter.core.orchestration.catalog_builder import (
    CommandInput,
    ServerInput,
    build_catalog_and_tool_context,
)
from mcp_proxy_adapter.core.orchestration.candidate_validator import (
    ValidationResult,
    validate_candidate,
)
from mcp_proxy_adapter.core.orchestration.discovery_client import (
    DiscoveryCandidate,
    run_discovery,
)
from mcp_proxy_adapter.core.orchestration.snapshot_holder import SnapshotHolder

logger = logging.getLogger(__name__)


class _PollingConfig(Protocol):
    """Protocol for config objects used by the polling loop."""

    @property
    def discovery_enabled(self) -> bool:
        """Whether discovery is enabled."""
        ...

    @property
    def polling_interval(self) -> Union[float, int]:
        """Polling interval in seconds."""
        ...

    @property
    def initial_poll_timeout(self) -> Union[float, int]:
        """Timeout for initial poll / validation in seconds."""
        ...

    @property
    def static_servers(self) -> Optional[Sequence[Any]]:
        """Optional static server list (host, port, id)."""
        ...


def _default_config() -> _PollingConfig:
    """Return a minimal config with defaults for use when config is None (e.g. in tests)."""

    class _Default:
        discovery_enabled = True
        polling_interval = 30.0
        initial_poll_timeout = 60.0
        static_servers: Optional[List[Any]] = None

    return _Default()


def _candidate_to_server_input(candidate: DiscoveryCandidate) -> ServerInput:
    """
    Convert a validated DiscoveryCandidate to ServerInput for catalog_builder.

    If commands_response contains "error", the server is included with empty
    commands list. Otherwise parses commands from response (GET /commands shape).
    """
    commands: List[Dict[str, Any]] = []
    resp = candidate.commands_response
    if not resp or "error" in resp:
        return {
            "server_id": candidate.id,
            "host": candidate.host,
            "port": candidate.port,
            "commands": [],
        }
    raw_commands = resp.get("commands")
    if isinstance(raw_commands, dict):
        for name, meta in raw_commands.items():
            if not isinstance(meta, dict):
                continue
            schema = meta.get("schema")
            if not isinstance(schema, dict):
                continue
            cmd: Dict[str, Any] = {"name": name, "schema": schema}
            if "description" in meta:
                cmd["metadata"] = {"description": meta.get("description")}
            commands.append(cmd)
    return {
        "server_id": candidate.id,
        "host": candidate.host,
        "port": candidate.port,
        "commands": cast(Sequence[CommandInput], commands),
    }


async def run_one_cycle(
    proxy_url: str,
    config: Optional[_PollingConfig],
    snapshot_holder: SnapshotHolder,
) -> bool:
    """
    Run one full FR-7 pipeline: discover -> validate -> filter -> build -> atomic swap.

    (1) Calls run_discovery(proxy_url, static_servers) to get candidates (proxy + static).
    (2) For each candidate calls validate_candidate (reachable, ID match); filters out
        invalid; logs ID mismatch, invalid ID, unreachable per TZ FR-7.
    (3) Builds CatalogSnapshot and ToolContextSnapshot from filtered list via
        build_catalog_and_tool_context.
    (4) Calls snapshot_holder.swap(new_catalog, new_tool_context) only on full success.

    On discovery failure or build failure, does not swap; last valid snapshot remains
    (NFR-3). Logs cycle start/end, success/failure, snapshot version, validation
    failures (NFR-4).

    Args:
        proxy_url: Base URL of mcp-proxy (e.g. http://localhost:3005).
        config: Config with discovery_enabled, polling_interval, initial_poll_timeout,
                static_servers. May be None to use defaults (e.g. in tests).
        snapshot_holder: Holder to swap new snapshots into on success.

    Returns:
        True if cycle completed and snapshot was swapped; False otherwise.
    """
    cfg = config if config is not None else _default_config()
    logger.info(
        "Polling cycle started (proxy_url=%s, discovery_enabled=%s)",
        proxy_url,
        getattr(cfg, "discovery_enabled", None),
    )
    try:
        candidates = await run_discovery(
            proxy_url,
            list(cfg.static_servers) if cfg.static_servers else None,
        )
    except Exception as exc:
        logger.error(
            "Polling cycle: discovery failed, keeping last snapshot: %s",
            exc,
            exc_info=False,
        )
        logger.info("Polling cycle ended: failure (discovery)")
        return False

    timeout = float(getattr(cfg, "initial_poll_timeout", 60.0))
    valid_candidates: List[DiscoveryCandidate] = []
    for c in candidates:
        protocol = "https" if c.scheme == "https" else "http"
        result: ValidationResult = await validate_candidate(
            c.host,
            c.port,
            c.id,
            protocol=protocol,
            timeout=timeout,
            verify_ssl=False,
        )
        if result.valid:
            valid_candidates.append(c)
        else:
            logger.warning(
                "Polling cycle: candidate %s:%s filtered out: reason=%s, reported_id=%s",
                c.host,
                c.port,
                result.reason,
                result.reported_id,
            )

    filtered_servers: List[ServerInput] = [
        _candidate_to_server_input(c) for c in valid_candidates
    ]

    if not filtered_servers:
        logger.warning(
            "Polling cycle: no valid candidates after filtering; keeping last snapshot"
        )
        logger.info("Polling cycle ended: failure (no active servers)")
        return False

    try:
        previous_version = snapshot_holder.get_version()
        catalog, tool_context = build_catalog_and_tool_context(
            filtered_servers,
            previous_version=previous_version,
        )
    except Exception as exc:
        logger.error(
            "Polling cycle: catalog build failed, keeping last snapshot: %s",
            exc,
            exc_info=False,
        )
        logger.info("Polling cycle ended: failure (build)")
        return False

    snapshot_holder.swap(catalog, tool_context)
    new_version = catalog.version
    logger.info(
        "Polling cycle ended: success; snapshot version=%s, active_servers=%s",
        new_version,
        len(valid_candidates),
    )
    return True


def run_one_cycle_sync(
    proxy_url: str,
    config: Optional[_PollingConfig],
    snapshot_holder: SnapshotHolder,
) -> bool:
    """
    Run one polling cycle synchronously (for callers without an event loop).

    Creates a new event loop, runs run_one_cycle once, closes the loop.
    Useful in tests or when invoking from a sync context.

    Args:
        proxy_url: Base URL of mcp-proxy.
        config: Config or None for defaults.
        snapshot_holder: Holder to update on success.

    Returns:
        True if cycle completed and snapshot was swapped; False otherwise.
    """
    return asyncio.run(run_one_cycle(proxy_url, config, snapshot_holder))


def run_polling_loop(
    proxy_url: str,
    config: Optional[_PollingConfig],
    snapshot_holder: SnapshotHolder,
    stop_event: threading.Event,
) -> None:
    """
    Run the polling cycle periodically in the current thread until stop_event is set.

    Creates a new asyncio event loop in this thread and runs run_one_cycle at each
    interval. Intended to be started by lifespan as a background thread. Does not
    start the thread itself; the caller must run this function in a thread.

    Threading model: This function blocks and should be invoked as the target of
    a threading.Thread. The loop runs run_one_cycle (async) via loop.run_until_complete,
    then waits for polling_interval or until stop_event is set.

    Args:
        proxy_url: Base URL of mcp-proxy.
        config: Config with polling_interval and other settings.
        snapshot_holder: Holder to update on each successful cycle.
        stop_event: When set, the loop exits after the current cycle (or immediately
                    if waiting on interval).
    """
    cfg = config if config is not None else _default_config()
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)
    interval = float(cfg.polling_interval)
    try:
        while not stop_event.is_set():
            loop.run_until_complete(run_one_cycle(proxy_url, cfg, snapshot_holder))
            if stop_event.wait(timeout=interval):
                break
    finally:
        loop.close()
    logger.info("Polling loop stopped")
