"""Base channel interface for messaging platforms.

The base class owns three concerns shared across every messaging
integration:

1. **Lifecycle contract** — abstract ``connect`` / ``disconnect`` /
   ``send_message`` / ``send_notification`` / ``is_connected`` plus
   the ``on_message`` callback registration. Every channel implements
   these regardless of underlying transport (HTTP polling, WebSocket
   gateway, webhook receiver, local IPC).

2. **Health state machine** — a four-state ``ChannelHealth`` enum
   (``disconnected``/``active``/``dormant``/``failed``) that channels
   transition between. Polling-based channels need this to handle
   conflict signals from the remote API (e.g. Telegram 409, Baileys
   ``replaced`` event, Discord gateway close 4014). The state machine
   logic lives here so subclasses don't reimplement it.

3. **Cooperative-conflict primitives** — when a remote API tells us
   "another instance owns this resource", the well-behaved response
   is to back off, NOT to fight back or kill the other process. The
   base class provides ``_record_conflict()`` (count consecutive
   conflict signals; transition to dormant on threshold),
   ``_enter_dormant()`` / ``_exit_dormant()`` (state transitions with
   ActivityLogger emission), and ``_run_dormant_probe_cycle()`` (the
   slow-retry loop that tries to reclaim ownership). Subclasses
   override two hooks: ``_CONFLICT_THRESHOLD`` constant and
   ``_attempt_dormant_probe()`` async method that performs ONE
   platform-specific probe.

This is the architectural primitive that lets every polling channel
inherit identical conflict-handling semantics. Documented in
``CLAUDE.md`` under "Channel architecture" — when adding a new
polling channel, override ``_attempt_dormant_probe`` and call
``self._record_conflict(signal_id, details)`` whenever the remote
API surfaces a conflict signal. The state machine handles the rest.

Webhook-only channels (Teams, Messenger) and tolerant protocols
(Matrix — multiple clients allowed by spec) don't need the conflict
primitives; they can leave the base-class state at ``ACTIVE`` and
ignore the dormant-probe machinery.

The 2026-04-25 launcher post-mortem (see ``cli.py:_try_reclaim_port``)
established that we NEVER kill processes from outside the channel.
This file is the canonical place for the cooperative-shutdown
mechanism that replaces it.
"""

from __future__ import annotations

import asyncio
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import Enum
from typing import Any

log = logging.getLogger(__name__)


@dataclass
class ChannelMessage:
    """A message received from or sent to a channel."""

    channel: str
    sender: str
    text: str
    timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
    metadata: dict = field(default_factory=dict)


class ChannelHealth(str, Enum):
    """Lifecycle state of a channel.

    - ``DISCONNECTED``: not started OR cleanly stopped. Initial state.
    - ``ACTIVE``: connected and operating normally.
    - ``DORMANT``: connected but suspended due to a remote conflict
      signal (another instance owns the resource). The channel runs
      slow recovery probes via ``_run_dormant_probe_cycle()``.
    - ``FAILED``: unrecoverable error — bad credentials, banned token,
      protocol violation. Terminal until reconfigured.

    Transitions:

    - DISCONNECTED → ACTIVE         (connect succeeded)
    - ACTIVE → DORMANT              (consecutive conflicts ≥ threshold)
    - DORMANT → ACTIVE              (probe succeeded, conflict resolved)
    - ANY → DISCONNECTED            (disconnect called)
    - ANY → FAILED                  (unrecoverable error)
    """

    DISCONNECTED = "disconnected"
    ACTIVE = "active"
    DORMANT = "dormant"
    FAILED = "failed"


class Channel(ABC):
    """Abstract base for messaging channel integrations.

    Subclasses MUST implement ``connect`` / ``disconnect`` /
    ``send_message`` / ``send_notification`` / ``is_connected``.
    They MAY override the cooperative-conflict hooks below.
    """

    name: str = "unknown"

    # ── Cooperative-conflict configuration (override per subclass) ──

    # Number of consecutive conflict signals before transitioning to
    # DORMANT. Subclasses with chattier conflict semantics may raise
    # this; subclasses where conflict is unambiguous (Telegram 409)
    # can keep the default of 3.
    _CONFLICT_THRESHOLD: int = 3  # arch:deterministic — circuit-breaker count for transport-level conflicts; structural reliability knob, not user strategy

    # Sleep between dormant-probe attempts. The default (300s = 5 min)
    # balances log-noise minimization against recovery latency. After
    # the conflict resolves, the channel will be back online within
    # this window.
    _DORMANT_PROBE_INTERVAL: float = 300.0

    # ── Health state (managed by base class) ────────────────────────

    def __init__(self) -> None:
        # NOTE: subclasses that override ``__init__`` MUST call
        # ``super().__init__()`` so this state is initialized.
        # Channels that don't (most don't, today) get the default
        # values from class attributes — see compatibility shim below.
        self._init_health_state()

    def _init_health_state(self) -> None:
        """Idempotent initialization of health-state fields.

        Called from ``__init__`` AND from ``_record_conflict``/
        ``_enter_dormant`` as a safety net for subclasses that don't
        call ``super().__init__()``. Setting twice is harmless.
        """
        if not hasattr(self, "_health"):
            self._health: ChannelHealth = ChannelHealth.DISCONNECTED
        if not hasattr(self, "_consecutive_conflicts"):
            self._consecutive_conflicts: int = 0
        if not hasattr(self, "_dormant_since"):
            self._dormant_since: datetime | None = None
        if not hasattr(self, "_dormant_signal"):
            self._dormant_signal: str = ""
        if not hasattr(self, "_activity"):
            # ActivityLogger — wired post-init by main.py for channels
            # that participate in observability. ``None`` = no-op.
            self._activity: Any = None

    # ── Lifecycle abstract surface ─────────────────────────────────

    @abstractmethod
    async def connect(self) -> None:
        """Establish connection to the channel."""

    @abstractmethod
    async def disconnect(self) -> None:
        """Disconnect from the channel."""

    @abstractmethod
    async def send_message(self, chat_id: str, text: str) -> None:
        """Send a message to a specific chat."""

    @abstractmethod
    async def send_notification(self, chat_id: str, text: str) -> None:
        """Send a notification (may differ from message in formatting)."""

    @property
    @abstractmethod
    def is_connected(self) -> bool:
        """Whether the channel is currently connected."""

    def on_message(self, callback) -> None:  # noqa: ANN001
        """Register a callback for incoming messages."""
        self._message_callback = callback

    # ── Health-state read surface ──────────────────────────────────

    @property
    def health(self) -> ChannelHealth:
        """Current health state. Read-only — use the helper methods
        below to transition."""
        self._init_health_state()
        return self._health

    @property
    def is_dormant(self) -> bool:
        """True iff the channel is suspended due to a remote conflict."""
        self._init_health_state()
        return self._health == ChannelHealth.DORMANT

    @property
    def consecutive_conflicts(self) -> int:
        """How many conflict signals have hit since the last reset."""
        self._init_health_state()
        return self._consecutive_conflicts

    def health_summary(self) -> dict[str, Any]:
        """Compact dict for status endpoints / get_info()."""
        self._init_health_state()
        return {
            "health": self._health.value,
            "consecutive_conflicts": self._consecutive_conflicts,
            "dormant_since": self._dormant_since.isoformat() if self._dormant_since else None,
            "dormant_signal": self._dormant_signal,
        }

    # ── Cooperative-conflict state transitions ──────────────────────

    def _mark_active(self) -> None:
        """Set health to ACTIVE and reset the conflict counter.

        Call this from ``connect()`` after a successful connection,
        and inline after every successful poll/event-receive (so a
        run of conflict signals isn't cumulative across successes).
        """
        self._init_health_state()
        self._health = ChannelHealth.ACTIVE
        self._consecutive_conflicts = 0

    def _mark_disconnected(self) -> None:
        """Set health to DISCONNECTED. Call from ``disconnect()``."""
        self._init_health_state()
        self._health = ChannelHealth.DISCONNECTED
        self._consecutive_conflicts = 0
        self._dormant_since = None
        self._dormant_signal = ""

    def _mark_failed(self, reason: str) -> None:
        """Set health to FAILED — terminal, requires reconfiguration."""
        self._init_health_state()
        log.error("[%s] channel failed: %s", self.name, reason)
        self._health = ChannelHealth.FAILED
        self._emit_health_event(
            "failed",
            f"{self.name} channel entered FAILED state",
            {"reason": reason},
            importance="high",
        )

    def _record_conflict(self, signal_id: str, details: dict[str, Any]) -> None:
        """Record one occurrence of a conflict signal.

        ``signal_id`` is a short machine code identifying the kind of
        conflict (e.g. ``"telegram_409"``, ``"baileys_replaced"``).
        ``details`` is a dict with platform-specific context for the
        ActivityLogger event.

        After ``_CONFLICT_THRESHOLD`` consecutive calls without an
        intervening ``_mark_active()``, the channel transitions to
        DORMANT and emits a high-importance event. Pre-threshold
        signals log a warning so the user still sees them.
        """
        self._init_health_state()
        self._consecutive_conflicts += 1
        if (
            self._consecutive_conflicts >= self._CONFLICT_THRESHOLD
            and self._health != ChannelHealth.DORMANT
        ):
            self._enter_dormant(signal_id, details)
        else:
            log.warning(
                "[%s] conflict signal %s (%d/%d): %s",
                self.name,
                signal_id,
                self._consecutive_conflicts,
                self._CONFLICT_THRESHOLD,
                details,
            )

    def _enter_dormant(self, signal_id: str, details: dict[str, Any]) -> None:
        """Transition into DORMANT state. Idempotent."""
        self._init_health_state()
        if self._health == ChannelHealth.DORMANT:
            return
        self._health = ChannelHealth.DORMANT
        self._dormant_since = datetime.now(UTC)
        self._dormant_signal = signal_id
        log.warning(
            "[%s] entering DORMANT — %d consecutive conflict signals (%s). "
            "Will probe every %ds for recovery.",
            self.name,
            self._consecutive_conflicts,
            signal_id,
            int(self._DORMANT_PROBE_INTERVAL),
        )
        self._emit_health_event(
            "dormant",
            f"{self.name} polling paused — another instance owns the resource",
            {
                "signal_id": signal_id,
                "consecutive_conflicts": self._consecutive_conflicts,
                "probe_interval_seconds": self._DORMANT_PROBE_INTERVAL,
                **details,
            },
            importance="high",
        )

    def _exit_dormant(self) -> None:
        """Transition out of DORMANT back to ACTIVE. Idempotent."""
        self._init_health_state()
        if self._health != ChannelHealth.DORMANT:
            return
        was_dormant_for: float | None = None
        if self._dormant_since is not None:
            was_dormant_for = (datetime.now(UTC) - self._dormant_since).total_seconds()
        signal_id = self._dormant_signal
        self._health = ChannelHealth.ACTIVE
        self._consecutive_conflicts = 0
        self._dormant_since = None
        self._dormant_signal = ""
        log.info(
            "[%s] exiting DORMANT — resource ownership reclaimed (was dormant for %.0fs)",
            self.name,
            was_dormant_for or 0.0,
        )
        self._emit_health_event(
            "resumed",
            f"{self.name} polling resumed — resource ownership reclaimed",
            {
                "dormant_seconds": was_dormant_for,
                "signal_id": signal_id,
            },
            importance="medium",
        )

    # ── Dormant probe orchestration ────────────────────────────────

    async def _run_dormant_probe_cycle(self) -> None:
        """Sleep one probe interval, then attempt one recovery probe.

        Subclasses do NOT typically call this directly — they call it
        from inside their poll loop when ``is_dormant`` is True. The
        loop body does:

            while self._connected:
                if self.is_dormant:
                    await self._run_dormant_probe_cycle()
                    continue
                # ... normal poll ...

        On probe success → ``_exit_dormant()`` is called. On failure
        → stays dormant. Cancellation-aware so ``disconnect()`` can
        break out cleanly during the long sleep.
        """
        try:
            await asyncio.sleep(self._DORMANT_PROBE_INTERVAL)
        except asyncio.CancelledError:
            raise

        if not self._connected_or_default():
            return

        try:
            reclaimed = await self._attempt_dormant_probe()
        except asyncio.CancelledError:
            raise
        except Exception:
            log.debug("[%s] dormant probe raised", self.name, exc_info=True)
            return

        if reclaimed:
            self._exit_dormant()
        else:
            log.debug("[%s] dormant probe — still in conflict", self.name)

    async def _attempt_dormant_probe(self) -> bool:
        """Subclass override: perform ONE platform-specific probe.

        Returns True iff the conflict has resolved and we should
        transition back to ACTIVE. Returns False to stay dormant.
        Should NOT raise on the conflict path — only on truly
        unexpected errors.

        Default implementation returns False — channels that never
        encounter conflicts (webhook-only) won't ever enter DORMANT
        anyway, so this default is correct for them.
        """
        return False

    # ── ActivityLogger emission ────────────────────────────────────

    def _emit_health_event(
        self,
        sub_action: str,
        summary: str,
        details: dict[str, Any],
        *,
        importance: str = "medium",
    ) -> None:
        """Emit a channel health-transition event.

        Action name is ``"<channel_name>_<sub_action>"`` — e.g.
        ``telegram_dormant``, ``whatsapp_resumed``, ``slack_failed``.
        Namespaced so different channels' events don't collide.

        No-op when ``self._activity`` is None (test mode, fresh boot
        before main.py wires it). Never raises — a broken logger
        must not break the state machine.
        """
        self._init_health_state()
        logger = getattr(self, "_activity", None)
        if logger is None:
            return
        try:
            logger.log(
                category="channel",
                action=f"{self.name}_{sub_action}",
                summary=summary,
                details={"channel": self.name, **details},
                importance=importance,
            )
        except Exception:
            log.debug("[%s] ActivityLogger.log failed", self.name, exc_info=True)

    # ── Internals ──────────────────────────────────────────────────

    def _connected_or_default(self) -> bool:
        """Read ``self._connected`` if set (most subclasses use it),
        else fall back to ``is_connected`` property. Defensive: the
        attribute name isn't enforced by the ABC."""
        if hasattr(self, "_connected"):
            return bool(self._connected)
        try:
            return self.is_connected
        except Exception:
            return False
