"""
Chatto Platform Config


"""

import os
import re

# Put the vendored dependencies for THIS platform on sys.path before importing
# anything from chattolib. Imported relatively as part of the plugin package and
# absolutely when this module is loaded standalone (e.g. by the tests).
try:
    from .vendor_path import setup_vendor_path
except ImportError:  # pragma: no cover - depends on how the module is loaded
    from vendor_path import setup_vendor_path

setup_vendor_path()

import logging
from typing import Any, ClassVar, Generic, TypeVar

import utils

# Absolute import — the vendor dir is on sys.path (see above) and chattolib's
# own modules import each other absolutely ("from chattolib.x import y").
# Importing it relatively as well would load a *second* copy of every module
# under a different name, so isinstance() checks across the two would fail.
from chattolib import ChattoClient
from gateway.config import PlatformConfig

logger = logging.getLogger(__name__)

# --------------------------------------------------------------------------- #
# Constants
# --------------------------------------------------------------------------- #


class ChattoConstants:
    """
    Chatto Platform Constants
    """

    def __init__(self):
        pass

    PLATFORM_NAME: str = "chatto-platform"
    PLATFORM_LABEL: str = "Chatto"

    INSTALL_HINT = "Requires a Chatto server. See https://docs.chatto.run"

    # NOTE: env var names live on the ConfigFields below (ConfigField.env_name),
    # so there is exactly one source of truth for them.

    MAX_MESSAGE_LENGTH = 10000
    SEEN_CAP = 500

    # WebSocket / realtime protocol
    WS_PATH = "/api/realtime"
    WS_AUTH_TIMEOUT = 20.0
    WS_MAX_MESSAGE_BYTES = 4_000_000
    WS_PING_INTERVAL = 30.0
    WS_PING_FAILURE_THRESHOLD = 3
    WS_RECONNECT_INITIAL_BACKOFF = 1.0
    WS_RECONNECT_MAX_BACKOFF = 30.0

    # A candidate @-handle, not a confirmed one — every match is resolved
    # against the member directory before it counts. Must start alphanumeric,
    # so prose like "per @-mention" yields nothing to look up.
    # The lookbehind keeps the domain half of an e-mail address from becoming a
    # candidate and costing a directory lookup.
    MENTION_RE = re.compile(r"(?<![\w.])@([A-Za-z0-9_][A-Za-z0-9_.\-]*)")

    # Handles that address the room rather than a person — the bot is one of
    # the addressees, so these do not mean "this is for someone else".
    BROADCAST_MENTIONS = frozenset({"here", "channel", "everyone", "all", "room"})

    # Presence is a TTL the server lets lapse — chattolib refuses to set OFFLINE
    # at all ("stop refreshing to go offline"), so staying online means
    # re-announcing ONLINE on this interval for as long as we are connected.
    PRESENCE_REFRESH_INTERVAL = 60.0

    # HTTP timeout used for outbound URL fetches
    HTTP_TIMEOUT = 30

    # Emoji shortcode mapping (Chatto uses shortcode names, not unicode emoji)
    EMOJI_TO_SHORTCODE: ClassVar[dict[str, str]] = {
        "👍": "thumbsup",
        "👎": "thumbsdown",
        "❤️": "heart",
        "❤": "heart",
        "✅": "white_check_mark",
        "❌": "x",
        "🚫": "no_entry_sign",
        "⛔": "no_entry",
        "👀": "eyes",
        "🫥": "dotted_line_face",
        "🎉": "tada",
        "😂": "joy",
        "🚀": "rocket",
        "🔥": "fire",
        "💯": "100",
        "🤔": "thinking_face",
        "👏": "clap",
        "🙏": "pray",
        "😅": "sweat_smile",
        "😴": "sleeping",
        "⏳": "hourglass",
        "💭": "thought_balloon",
        "👓": "glasses",
        "👁️‍🗨️": "eye_in_speech_bubble",
        "💬": "speech_balloon",
        "🗨️": "left_speech_bubble",
        "🗯️": "right_anger_bubble",
        "🦾": "mechanical_arm",
        "🦿": "mechanical_leg",
    }

    # Chunk size for asset uploads (256 KB)
    UPLOAD_CHUNK_SIZE = 256 * 1024


def _get_env_or_extra_str_opt(
    env_var: str, extra_val: str | bool | None, default: str | None = None
) -> str | None:
    """Get a value from environment variable or extra config."""
    env_value = os.getenv(env_var)
    if env_value is not None:
        return env_value.strip()

    if extra_val is not None:
        if isinstance(extra_val, str):
            return extra_val.strip()
        elif isinstance(extra_val, bool):
            return str(extra_val)
        else:
            logger.error(
                "extra_val: %s is supposed to be str, but %s was found.",
                extra_val,
                str(type(extra_val)),
            )

    if default is not None:
        logger.debug("Chatto: Defaulting to '%s'", default)
        return default.strip()
    return None


def _get_env_or_extra_str(
    env_var: str, extra_val: str | bool | None, default: str | None = None
) -> str:
    """Get a value from environment variable or extra config."""
    my_string = _get_env_or_extra_str_opt(env_var, extra_val, default)
    if my_string:
        return my_string
    return ""


def _get_env_or_extra_truthy(
    env_var: str, extra_val: str | bool | None, default: bool = False
) -> bool:
    """Get a boolean value from environment variable or extra config."""
    return utils.is_truthy_value(
        _get_env_or_extra_str(env_var, extra_val, str(default)), default
    )


def _split_str_to_list(mystring: str) -> list[str]:
    """Split a comma-separated string, trimming whitespace and dropping empties."""
    return [part.strip() for part in mystring.split(",") if part.strip()]


def _get_env_or_extra_list(env_var: str, extra_val: list[str] | None) -> list[str]:
    """Get a list of values from environment variable or extra config."""
    env_value = os.getenv(env_var)
    if env_value is not None:
        return _split_str_to_list(env_value)

    if extra_val is not None:
        if isinstance(extra_val, list):
            return [str(c).strip() for c in extra_val if str(c).strip()]
        if isinstance(extra_val, str):
            return _split_str_to_list(extra_val)

    return []


def _get_env_or_extra_int(env_var: str, extra_val: Any, default: int) -> int:
    """Get an integer value from environment variable or extra config.

    Unparseable values fall back to the default with a warning rather than
    raising — a typo in an env var must not keep the plugin from starting.
    """
    raw = os.getenv(env_var)
    if raw is None or not raw.strip():
        raw = extra_val
    if isinstance(raw, bool):  # a YAML true/false is not a number
        raw = None
    elif isinstance(raw, (int, float)):
        return int(raw)
    elif isinstance(raw, str) and raw.strip():
        try:
            return int(raw.strip())
        except ValueError:
            logger.warning(
                "Chatto: %s=%r is not an integer, using default %d",
                env_var,
                raw,
                default,
            )
    return default


T = TypeVar("T")


class ConfigValue(Generic[T]):
    """The resolved value of a single config field, bound to one configuration
    instance.

    Access the payload via ``.value``.  ``__bool__``/``__eq__`` delegate to it,
    so a forgotten ``.value`` (``if config.allow_all_users:``) still evaluates
    the actual setting instead of the always-truthy wrapper object.
    """

    __slots__ = ("env_name", "field_name", "value")

    def __init__(self, value: T, field_name: str, env_name: str) -> None:
        self.value = value
        self.field_name = field_name
        self.env_name = env_name

    def __bool__(self) -> bool:
        return bool(self.value)

    def __eq__(self, other: object) -> bool:
        if isinstance(other, ConfigValue):
            return self.value == other.value
        return self.value == other

    def __hash__(self) -> int:
        return hash(self.value)

    def __contains__(self, item: Any) -> bool:
        return item in self.value  # type: ignore[operator]

    def __iter__(self):
        return iter(self.value)  # type: ignore[call-overload]

    def __str__(self) -> str:
        return str(self.value)

    def __repr__(self) -> str:
        return f"{self.field_name}={self.value!r}"


class ConfigField(Generic[T]):
    """Declarative descriptor for one config field.

    Declares *how* a field is read (kind, env var name, default); the resolved
    payload lives per configuration instance in ``instance._values``, never on
    the descriptor itself.  Reading a field on the class (rather than on an
    instance) yields the descriptor, so ``ChattoConfiguration.token.env_name``
    keeps working for the registration hooks.
    """

    field_name: str = ""
    env_name: str = ""

    def __init__(
        self,
        kind: str,
        *,
        default: Any = None,
        config_key: str | None = None,
        doc: str = "",
    ) -> None:
        # kind: "str" | "str_opt" | "bool" | "list"
        self.kind = kind
        self.default = default
        # config_key: the name used in config.yaml's "extra" block and (upper-cased,
        # CHATTO_-prefixed) as the env var, where it differs from the attribute name.
        self._config_key = config_key
        self.__doc__ = doc

    def __set_name__(self, owner: Any, name: str) -> None:
        self.field_name = str(name).lower().replace("chatto_", "", 1)
        self.config_key = self._config_key or self.field_name
        self.env_name = f"CHATTO_{self.config_key.upper()}"

    def __get__(self, instance: Any, owner: Any = None) -> "ConfigValue[T]":
        if instance is None:
            return self  # type: ignore[return-value]
        return instance._values[self.field_name]

    def __set__(self, instance: Any, value: Any) -> None:
        raise AttributeError(
            f"Chatto: config field '{self.field_name}' is read-only; "
            f"set '{self.field_name}.value' if you really need to override it."
        )

    def resolve(self, extra: dict[str, Any]) -> ConfigValue:
        """Read this field from the environment, then from ``extra``, then the default."""
        raw = extra.get(self.config_key)

        if self.kind == "list":
            value: Any = _get_env_or_extra_list(self.env_name, raw)
        elif self.kind == "bool":
            value = _get_env_or_extra_truthy(self.env_name, raw, bool(self.default))
        elif self.kind == "int":
            value = _get_env_or_extra_int(self.env_name, raw, int(self.default))
        elif self.kind == "str_opt":
            value = _get_env_or_extra_str_opt(self.env_name, raw, self.default)
        else:
            value = _get_env_or_extra_str(self.env_name, raw, self.default)

        return ConfigValue(value, self.field_name, self.env_name)


class ChattoConfiguration:
    """Chatto platform configuration.

    Every field is resolved once per instance, in this order:
    environment variable → ``PlatformConfig.extra`` → declared default.
    """

    base_url = ConfigField("str", default=ChattoClient.DEFAULT_BASE_URL)
    token = ConfigField("str_opt")
    login = ConfigField("str")
    password = ConfigField("str")
    home_channel = ConfigField("str")
    allowed_users = ConfigField("list")
    require_mention = ConfigField("bool", default=False)
    # free_response_channels: room IDs where the bot responds without being
    # mentioned via "@botname" even when require_mention is true.
    free_response_channels_list = ConfigField(
        "list", config_key="free_response_channels"
    )
    # respond_rooms: positive list of room IDs the bot reads and answers in.
    # Rooms outside the list stay joined and marked as read, but are never
    # seeded into context or answered; DMs are exempt. Empty means every
    # joined room is a respond room (the behaviour before this knob existed).
    respond_rooms = ConfigField("list")
    # Auto-thread: by default, Chatto creates a thread for replies to room
    # messages (not DMs, not already in a thread). This keeps conversations
    # organized in the room. Can be disabled via extra.auto_thread=false.
    auto_thread = ConfigField("bool", default=True)
    allow_all_users = ConfigField("bool", default=False)
    reactions = ConfigField("bool", default=True)
    # Inbound edits: a message_edited event re-runs the admission gates against
    # the new body. A message currently being processed is cancelled and
    # re-dispatched with the corrected text; a message that never passed the
    # gates (e.g. a forgotten @mention) gets a fresh turn; an already-answered
    # message stays answered.
    edit_dispatch = ConfigField("bool", default=True)
    # How long after posting an edit may still land, in seconds — edits to
    # hours-old messages must not resurrect old conversations. Parsed by
    # ConfigField's "int" kind; unparseable input falls back to the default.
    edit_window = ConfigField("int", default=300)

    def __init__(self, pconfig: PlatformConfig):
        """Resolve every declared ConfigField against env vars and
        ``PlatformConfig.extra`` (which Hermes pre-populates from config.yaml).
        """
        extra: dict[str, Any] = getattr(pconfig, "extra", None) or {}

        self._values: dict[str, ConfigValue] = {
            field.field_name: field.resolve(extra) for field in self.fields()
        }

        logger.debug("ChattoConfiguration: %s", self)

    @classmethod
    def fields(cls) -> list[ConfigField]:
        """All declared config fields, in declaration order."""
        return [v for v in vars(cls).values() if isinstance(v, ConfigField)]

    def __str__(self) -> str:
        redacted = {"token", "password"}
        parts = [
            f"{name}={'***' if name in redacted and cv.value else cv.value!r}"
            for name, cv in self._values.items()
        ]
        return "ChattoConfiguration(" + ", ".join(parts) + ")"
