"""Blue-green index lifecycle management for OpenSearch.

Provides a reusable, zero-downtime reindex strategy using a blue-green
deployment pattern.  New documents are indexed into a standby index while
the live alias continues serving queries.  Once indexing succeeds the alias
is atomically switched and the old index is deleted.

The module is intentionally generic — callers supply their own alias names,
index bodies (settings + mappings), and documents.  Any naming convention or
document shape can be used.

Typical usage::

    from dtpyfw.opensearch import OpenSearchClient, OpenSearchConfig, BlueGreenManager

    config = OpenSearchConfig().set_url("https://localhost:9200")
    client = OpenSearchClient(config)
    manager = BlueGreenManager(client)

    success = manager.reindex(
        alias="products_tenant_123",
        index_body={"settings": {...}, "mappings": {...}},
        documents=[{"id": "a1", "name": "Widget"}, ...],
    )
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Iterable, Mapping

from opensearchpy import NotFoundError, OpenSearch, RequestError, helpers

from ..core.exception import exception_to_dict
from ..log import footprint
from .client import OpenSearchClient

__all__ = (
    "IndexState",
    "BlueGreenManager",
)

# ---------------------------------------------------------------------------
# Colour suffixes
# ---------------------------------------------------------------------------
_BLUE_SUFFIX: str = "_blue"
_GREEN_SUFFIX: str = "_green"
_LARGE_BATCH_THRESHOLD: int = 100_000

# ---------------------------------------------------------------------------
# Index state snapshot
# ---------------------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class IndexState:
    """Immutable snapshot of the blue / green / alias state for one alias.

    Attributes:
        blue_exists: Whether the blue index exists.
        green_exists: Whether the green index exists.
        legacy_exists: Whether a concrete index with the same name as the
            alias exists (blocks alias creation).
        alias_targets: The set of concrete indexes currently behind the alias.
    """

    blue_exists: bool
    green_exists: bool
    legacy_exists: bool
    alias_targets: frozenset[str] = field(default_factory=frozenset)

    @property
    def live_index(self) -> str | None:
        """The single index currently behind the alias, or ``None``."""
        if len(self.alias_targets) == 1:
            return next(iter(self.alias_targets))
        return None


# ---------------------------------------------------------------------------
# Manager
# ---------------------------------------------------------------------------


class BlueGreenManager:
    """Zero-downtime blue-green index manager for OpenSearch.

    Wraps an :class:`~dtpyfw.opensearch.OpenSearchClient` and exposes high-level
    operations: :meth:`reindex`, :meth:`cleanup`, and :meth:`get_state`.

    All methods obtain an ``OpenSearch`` connection from the wrapped client via
    its context manager, so callers never need to manage raw connections.

    Args:
        client: A configured :class:`OpenSearchClient` instance.
        chunk_size: Number of documents per bulk-index batch (default 200).
        request_timeout: Timeout in seconds for bulk-index calls (default 120).
        cleanup_on_empty: When ``True``, calling :meth:`reindex` with an
            empty *documents* iterable will remove the blue/green indexes
            and alias.  When ``False`` (default), empty documents are a
            no-op and the method returns ``True`` immediately.

    Example:
        >>> from dtpyfw.opensearch import OpenSearchConfig, OpenSearchClient
        >>> from dtpyfw.opensearch.blue_green import BlueGreenManager
        >>>
        >>> config = OpenSearchConfig().set_url("https://localhost:9200")
        >>> client = OpenSearchClient(config)
        >>> manager = BlueGreenManager(client)
        >>>
        >>> manager.reindex(
        ...     alias="inventory_dealer_abc",
        ...     index_body={"settings": {"number_of_shards": 1}, "mappings": {...}},
        ...     documents=[{"id": "1", "title": "Widget"}],
        ... )
        True
    """

    def __init__(
        self,
        client: OpenSearchClient,
        *,
        chunk_size: int = 200,
        request_timeout: int = 120,
        cleanup_on_empty: bool = False,
    ) -> None:
        self._client = client
        self._chunk_size = chunk_size
        self._request_timeout = request_timeout
        self._cleanup_on_empty = cleanup_on_empty

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def reindex(
        self,
        alias: str,
        index_body: Mapping[str, Any],
        documents: Iterable[Mapping[str, Any]],
        *,
        id_field: str = "id",
        log_context: Mapping[str, Any] | None = None,
    ) -> bool:
        """Replace all documents in an alias using blue-green deployment.

        When *documents* is non-empty, the function indexes them into a fresh
        standby index, atomically switches the alias, and deletes the old
        index.  When *documents* is empty, :meth:`cleanup` is called instead.

        Args:
            alias: The alias name that clients query against.  Must **not**
                end with ``_blue`` or ``_green``.
            index_body: Full index definition (``settings`` + ``mappings``).
            documents: Iterable of document dicts to index.  Each must contain
                a key matching *id_field*.
            id_field: Document key used as the OpenSearch ``_id``
                (default ``"id"``).
            log_context: Extra key-value pairs forwarded to every
                ``footprint.leave`` call (e.g. ``{"dealer_id": uuid}``).

        Returns:
            ``True`` if the alias was switched (or cleaned up) successfully.

        Raises:
            ValueError: If *alias* ends with a colour suffix.
        """
        controller = f"{__name__}.BlueGreenManager.reindex"
        
        self._validate_alias(alias)
        ctx = dict(log_context or {})

        try:
            with self._client.get_client() as os_client:
                # Materialise to a list so we can check emptiness.
                docs = list(documents)
                if not docs:
                    if self._cleanup_on_empty:
                        self._cleanup(os_client, alias, ctx)
                    return True

                return self._blue_green_reindex(
                    os_client, alias, index_body, docs, id_field, ctx
                )
        except Exception as exc:
            footprint.leave(
                log_type="error",
                controller=controller,
                subject="Reindex failed",
                message=f"Failed to reindex '{alias}'.",
                payload={"alias": alias, "error": exception_to_dict(exc), **ctx},
            )
            return False

    def cleanup(
        self,
        alias: str,
        *,
        log_context: Mapping[str, Any] | None = None,
    ) -> bool:
        """Remove blue, green, legacy indexes and the alias.

        Safe to call regardless of current state — missing resources are
        silently skipped.

        Args:
            alias: The alias / base index name.
            log_context: Extra key-value pairs forwarded to log calls.

        Returns:
            ``True`` if the cleanup completed without error.
        """
        controller = f"{__name__}.BlueGreenManager.cleanup"
        self._validate_alias(alias)
        ctx = dict(log_context or {})

        try:
            with self._client.get_client() as os_client:
                self._cleanup(os_client, alias, ctx)
            return True
        except Exception as exc:
            footprint.leave(
                log_type="error",
                controller=controller,
                subject="Cleanup failed",
                message=f"Failed to clean up '{alias}'.",
                payload={"alias": alias, "error": exception_to_dict(exc), **ctx},
            )
            return False

    def get_state(self, alias: str) -> IndexState:
        """Return the current :class:`IndexState` for an alias.

        Args:
            alias: The alias / base index name.

        Returns:
            A frozen snapshot of blue/green/alias state.

        Raises:
            ValueError: If *alias* ends with a colour suffix.
        """
        self._validate_alias(alias)
        blue = f"{alias}{_BLUE_SUFFIX}"
        green = f"{alias}{_GREEN_SUFFIX}"
        with self._client.get_client() as os_client:
            return self._resolve_state(os_client, alias, blue, green)

    # ------------------------------------------------------------------
    # Validation
    # ------------------------------------------------------------------

    @staticmethod
    def _validate_alias(alias: str) -> None:
        """Raise :class:`ValueError` if *alias* already contains a colour suffix.

        Args:
            alias: Alias name to validate.
        """
        if alias.endswith(_BLUE_SUFFIX) or alias.endswith(_GREEN_SUFFIX):
            raise ValueError(
                f"Alias must not end with '{_BLUE_SUFFIX}' or '{_GREEN_SUFFIX}': {alias}"
            )

    # ------------------------------------------------------------------
    # State resolution
    # ------------------------------------------------------------------

    @staticmethod
    def _resolve_state(
        client: OpenSearch,
        alias: str,
        blue_index: str,
        green_index: str,
    ) -> IndexState:
        """Audit blue/green indexes and alias to build a consistent snapshot.

        Args:
            client: Raw OpenSearch client.
            alias: Alias name.
            blue_index: Full blue index name.
            green_index: Full green index name.

        Returns:
            Frozen :class:`IndexState` snapshot.
        """
        blue_exists: bool = client.indices.exists(index=blue_index)
        green_exists: bool = client.indices.exists(index=green_index)

        alias_targets: frozenset[str] = frozenset()
        try:
            info = client.indices.get_alias(name=alias)
            alias_targets = frozenset(info.keys())
        except NotFoundError:
            pass

        legacy_exists = bool(not alias_targets and client.indices.exists(index=alias))

        return IndexState(
            blue_exists=blue_exists,
            green_exists=green_exists,
            legacy_exists=legacy_exists,
            alias_targets=alias_targets,
        )

    # ------------------------------------------------------------------
    # Idempotent helpers
    # ------------------------------------------------------------------

    @staticmethod
    def _warm_up(client: OpenSearch, index: str) -> None:
        """Pre-cache filesystem segments on newly created index (best-effort).

        Args:
            client: Raw OpenSearch client.
            index: Index name to warm up.
        """
        try:
            client.search(index=index, body={"size": 0, "query": {"match_all": {}}})
        except Exception:
            pass

    @staticmethod
    def _validate_index_body(index_body: Mapping[str, Any], ctx: dict[str, Any]) -> None:
        """Log a warning when *index_body* is missing a ``mappings`` key.

        Args:
            index_body: Index definition to check.
            ctx: Extra logging context.
        """
        controller = f"{__name__}.BlueGreenManager._validate_index_body"
        if "mappings" not in index_body:
            footprint.leave(
                log_type="warning",
                controller=controller,
                subject="Index body missing mappings",
                message="The supplied index_body has no 'mappings' key — the index will use dynamic mappings.",
                payload=ctx,
            )

    @staticmethod
    def _ensure_fresh_index(
        client: OpenSearch,
        index: str,
        body: Mapping[str, Any],
        ctx: dict[str, Any],
    ) -> None:
        """Create an empty index, deleting any stale remnant first.

        Handles the narrow race window where a concurrent process re-creates
        the index between delete and create.  If the force-replace also races,
        a second retry is attempted.

        Args:
            client: Raw OpenSearch client.
            index: Target index name.
            body: Full index body (settings + mappings).
            ctx: Extra logging context.

        Raises:
            RequestError: If creation fails for a non-race reason after retries.
        """
        controller = f"{__name__}.BlueGreenManager._ensure_fresh_index"
        _delete_if_exists(client, index)
        try:
            client.indices.create(index=index, body=body)
            return
        except RequestError as exc:
            if exc.error != "resource_already_exists_exception":
                raise
            footprint.leave(
                log_type="warning",
                controller=controller,
                subject="Concurrent index race detected",
                message=f"Index '{index}' appeared between delete and create — force-replacing.",
                payload=ctx,
            )

        # Retry up to 2 more times for very tight race windows.
        for attempt in range(2):
            _delete_if_exists(client, index)
            try:
                client.indices.create(index=index, body=body)
                return
            except RequestError as exc:
                if exc.error != "resource_already_exists_exception":
                    raise
                if attempt == 1:
                    raise
                footprint.leave(
                    log_type="warning",
                    controller=controller,
                    subject="Persistent race condition",
                    message=f"Index '{index}' re-appeared on retry {attempt + 1} — retrying.",
                    payload=ctx,
                )

    # ------------------------------------------------------------------
    # Cleanup
    # ------------------------------------------------------------------

    @staticmethod
    def _cleanup(
        client: OpenSearch,
        alias: str,
        ctx: dict[str, Any],
    ) -> None:
        """Remove blue, green, legacy indexes and the alias.

        Args:
            client: Raw OpenSearch client.
            alias: Alias / base index name.
            ctx: Extra logging context.
        """
        controller = f"{__name__}.BlueGreenManager._cleanup"
        for suffix in (_BLUE_SUFFIX, _GREEN_SUFFIX):
            idx = f"{alias}{suffix}"
            if _delete_if_exists(client, idx):
                footprint.leave(
                    log_type="info",
                    controller=controller,
                    subject="Index deleted",
                    message=f"Deleted index '{idx}'.",
                    payload=ctx,
                )

        if _delete_if_exists(client, alias):
            footprint.leave(
                log_type="info",
                controller=controller,
                subject="Legacy index deleted",
                message=f"Deleted legacy concrete index '{alias}'.",
                payload=ctx,
            )

        _remove_alias(client, alias)

    # ------------------------------------------------------------------
    # Core blue-green reindex
    # ------------------------------------------------------------------

    def _blue_green_reindex(
        self,
        client: OpenSearch,
        alias: str,
        index_body: Mapping[str, Any],
        records: list[Mapping[str, Any]],
        id_field: str,
        ctx: dict[str, Any],
    ) -> bool:
        """Execute the full blue-green index deployment.

        Steps:
            1. Audit current state (blue/green existence, alias targets, legacy).
            2. Remove any legacy concrete index blocking the alias name.
            3. Normalise corrupted alias state (multiple or unknown targets).
            4. Determine live (old) and standby (new) index.
            5. Create fresh standby index.
            6. Bulk-index all records into the standby.
            7. Refresh and warm up the standby index.
            8. Atomically switch the alias.
            9. Delete old live index.

        Args:
            client: Raw OpenSearch client.
            alias: Alias name.
            index_body: Full index body.
            records: Document dicts to index.
            id_field: Key in each document used as ``_id``.
            ctx: Extra logging context.

        Returns:
            ``True`` if the reindex succeeded and the alias was switched.
        """
        controller = f"{__name__}.BlueGreenManager._blue_green_reindex"
        blue_index = f"{alias}{_BLUE_SUFFIX}"
        green_index = f"{alias}{_GREEN_SUFFIX}"

        # ── 1. Audit ────────────────────────────────────────────────────
        state = self._resolve_state(client, alias, blue_index, green_index)

        footprint.leave(
            log_type="info",
            controller=controller,
            subject="State audit",
            message=(
                f"blue={state.blue_exists}, green={state.green_exists}, "
                f"legacy={state.legacy_exists}, alias_targets={list(state.alias_targets)}"
            ),
            payload={"alias": alias, **ctx},
        )

        # ── 2. Remove legacy concrete index ─────────────────────────────
        if state.legacy_exists:
            _delete_if_exists(client, alias)
            footprint.leave(
                log_type="info",
                controller=controller,
                subject="Legacy index removed",
                message=f"Deleted legacy concrete index '{alias}' to free the alias name.",
                payload=ctx,
            )

        # ── 3. Normalise corrupted alias state ──────────────────────────
        if len(state.alias_targets) > 1:
            footprint.leave(
                log_type="warning",
                controller=controller,
                subject="Corrupted alias (multi-target)",
                message=f"Alias '{alias}' points to {list(state.alias_targets)} — resetting.",
                payload=ctx,
            )
            _remove_alias(client, alias)
            state = self._resolve_state(client, alias, blue_index, green_index)

        live = state.live_index
        if live is not None and live not in (blue_index, green_index):
            footprint.leave(
                log_type="warning",
                controller=controller,
                subject="Alias points to unknown index",
                message=f"Alias '{alias}' points to '{live}' (not blue/green) — removing.",
                payload=ctx,
            )
            _remove_alias(client, alias)
            live = None

        # ── 4. Determine live/standby ────────────────────────────────────
        if live == blue_index:
            new_index, old_index = green_index, blue_index
        elif live == green_index:
            new_index, old_index = blue_index, green_index
        else:
            new_index, old_index = blue_index, None

        # ── 5. Validate index body & create fresh standby index ────────
        self._validate_index_body(index_body, ctx)

        if len(records) >= _LARGE_BATCH_THRESHOLD:
            footprint.leave(
                log_type="warning",
                controller=controller,
                subject="Large document batch",
                message=(
                    f"Indexing {len(records):,} documents into '{new_index}' — "
                    f"consider streaming or partitioning for batches >= {_LARGE_BATCH_THRESHOLD:,}."
                ),
                payload={"alias": alias, "count": len(records), **ctx},
            )

        self._ensure_fresh_index(client, new_index, index_body, ctx)

        # ── 6. Bulk-index records ────────────────────────────────────────
        try:
            actions = [
                {"_index": new_index, "_id": str(r[id_field]), "_source": r}
                for r in records
            ]
        except KeyError as exc:
            footprint.leave(
                log_type="error",
                controller=controller,
                subject="Missing id_field in document",
                message=(
                    f"Document is missing the id_field '{id_field}'. "
                    f"Aborting reindex for alias '{alias}'."
                ),
                payload={"alias": alias, "id_field": id_field, **ctx},
            )
            _delete_if_exists(client, new_index)
            return False

        success_count = 0
        failed: list[Any] = []

        if actions:
            try:
                success_count, failed = helpers.bulk(
                    client=client,
                    actions=actions,
                    raise_on_error=False,
                    ignore_status=[404, 409],
                    chunk_size=self._chunk_size,
                    request_timeout=self._request_timeout,
                )
            except Exception as bulk_exc:
                footprint.leave(
                    log_type="error",
                    controller=controller,
                    subject="Bulk indexing exception",
                    message=f"helpers.bulk raised an exception for alias '{alias}'.",
                    payload={
                        "alias": alias,
                        "new_index": new_index,
                        "error": exception_to_dict(bulk_exc),
                        **ctx,
                    },
                )
                _delete_if_exists(client, new_index)
                return False

            failed_count = len(failed) if isinstance(failed, list) else failed
            if failed_count > 0:
                footprint.leave(
                    log_type="error",
                    controller=controller,
                    subject="Bulk indexing partially failed — aborting",
                    message=f"{failed_count} documents failed to index. Alias not switched.",
                    payload={
                        "alias": alias,
                        "successful": success_count,
                        "failed": failed_count,
                        "new_index": new_index,
                        "old_index": old_index or "none",
                        **ctx,
                    },
                )
                _delete_if_exists(client, new_index)
                return False

        footprint.leave(
            log_type="info",
            controller=controller,
            subject="Bulk indexing succeeded",
            message=f"{success_count} documents indexed into '{new_index}'.",
            payload={"alias": alias, "new_index": new_index, **ctx},
        )

        # ── 7. Refresh & warm up ─────────────────────────────────────────
        client.indices.refresh(index=new_index, params={"request_timeout": self._request_timeout})
        self._warm_up(client, new_index)

        # ── 8. Atomically switch alias ───────────────────────────────────
        alias_actions: list[dict[str, Any]] = []
        try:
            current_targets = set(client.indices.get_alias(name=alias).keys())
        except NotFoundError:
            current_targets = set()

        for target in current_targets:
            if client.indices.exists(index=target):
                alias_actions.append({"remove": {"index": target, "alias": alias}})
        alias_actions.append({"add": {"index": new_index, "alias": alias}})

        try:
            client.indices.update_aliases(body={"actions": alias_actions})
        except NotFoundError:
            try:
                client.indices.update_aliases(
                    body={"actions": [{"add": {"index": new_index, "alias": alias}}]}
                )
            except Exception:
                footprint.leave(
                    log_type="error",
                    controller=controller,
                    subject="Alias switch failed — cleaning orphan",
                    message=f"Could not switch alias '{alias}' to '{new_index}'. Deleting orphaned index.",
                    payload={"alias": alias, "new_index": new_index, **ctx},
                )
                _delete_if_exists(client, new_index)
                raise
        except Exception:
            footprint.leave(
                log_type="error",
                controller=controller,
                subject="Alias switch failed — cleaning orphan",
                message=f"Could not switch alias '{alias}' to '{new_index}'. Deleting orphaned index.",
                payload={"alias": alias, "new_index": new_index, **ctx},
            )
            _delete_if_exists(client, new_index)
            raise

        footprint.leave(
            log_type="info",
            controller=controller,
            subject="Alias switched",
            message=f"Alias '{alias}' now points to '{new_index}'.",
            payload={"alias": alias, "new_index": new_index, **ctx},
        )

        # ── 9. Delete old index ──────────────────────────────────────────
        if old_index:
            try:
                if _delete_if_exists(client, old_index):
                    footprint.leave(
                        log_type="info",
                        controller=controller,
                        subject="Old index deleted",
                        message=f"Deleted old index '{old_index}'.",
                        payload={"alias": alias, "old_index": old_index, **ctx},
                    )
            except Exception as del_exc:
                footprint.leave(
                    log_type="warning",
                    controller=controller,
                    subject="Old index deletion failed",
                    message=f"Failed to delete old index '{old_index}' — alias is already switched.",
                    payload={
                        "alias": alias,
                        "old_index": old_index,
                        "error": exception_to_dict(del_exc),
                        **ctx,
                    },
                )

        return True


# ---------------------------------------------------------------------------
# Module-level helpers (used by the class but also useful standalone)
# ---------------------------------------------------------------------------


def _delete_if_exists(client: OpenSearch, index: str) -> bool:
    """Delete an index if it exists.

    Args:
        client: Raw OpenSearch client.
        index: Index name.

    Returns:
        ``True`` if the index existed and was deleted.
    """
    try:
        client.indices.delete(index=index)
        return True
    except NotFoundError:
        return False


def _remove_alias(client: OpenSearch, alias: str) -> None:
    """Remove an alias from every index it points to (no-op if absent).

    Args:
        client: Raw OpenSearch client.
        alias: Alias name to remove.
    """
    try:
        client.indices.delete_alias(index="_all", name=alias)
    except NotFoundError:
        pass
