# Copyright (c) 2026 Pyry Haulos
#
# This software is released under the MIT License.
# https://opensource.org/licenses/MIT

"""Slack channel adapter.

Implements the ``ChannelAdapter`` protocol for Slack, wrapping
authorization, thread mapping, and Slack API message delivery.
"""

from __future__ import annotations

import logging
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from typing import cast

from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError

from airut._json_types import JsonDict
from airut.gateway.channel import (
    AuthenticationError,
    ChannelAdapter,
    ChannelSendError,
    ParsedMessage,
    PlanStreamer,
    RawMessage,
)
from airut.gateway.slack.authorizer import SlackAuthorizer
from airut.gateway.slack.config import SlackChannelConfig
from airut.gateway.slack.listener import SlackChannelListener
from airut.gateway.slack.plan_streamer import SlackPlanStreamer
from airut.gateway.slack.sanitize import sanitize_for_slack
from airut.gateway.slack.thread_store import SlackThreadStore


logger = logging.getLogger(__name__)

#: Maximum characters in a single Slack ``markdown`` block.
_MAX_BLOCK_CHARS = 12000

#: Maximum total characters before splitting into multiple messages.
_MAX_MESSAGE_CHARS = 13000

#: Maximum characters for a plain-text (no blocks) message before truncation.
_MAX_TEXT_CHARS = 40000

#: Maximum length for thread title.
_MAX_TITLE_LENGTH = 60

#: Maximum attachment download size (100 MB).
_MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024

#: Allowed hostnames for Slack file download URLs.  Only URLs on these
#: hosts will be fetched during attachment download.  This prevents the
#: bot token from being sent to non-Slack hosts if the event payload
#: were ever to contain unexpected URLs.
#:
#: ``files.slack.com`` — current ``url_private`` / ``url_private_download``
#: ``slack.com``       — legacy ``url_private`` format (``/files-pri/``)
_SLACK_FILE_HOSTS: frozenset[str] = frozenset({"files.slack.com", "slack.com"})


@dataclass
class SlackParsedMessage(ParsedMessage):
    """Slack-specific parsed message.

    Carries Slack threading info and deferred file download state
    alongside the channel-agnostic ``ParsedMessage`` fields.  The core
    sees a plain ``ParsedMessage``; the adapter downcasts to access
    Slack-specific fields when sending replies.
    """

    slack_channel_id: str = ""
    """DM channel ID (``D``-prefixed)."""

    slack_thread_ts: str = ""
    """Thread timestamp for reply threading."""

    slack_file_urls: list[tuple[str, str]] = field(default_factory=list)
    """List of ``(filename, download_url)`` for deferred download."""


class SlackChannelAdapter(ChannelAdapter):
    """``ChannelAdapter`` implementation for Slack (Socket Mode).

    Wraps ``SlackAuthorizer``, ``SlackThreadStore``, and the Slack
    ``WebClient`` behind the ``ChannelAdapter`` interface.
    """

    def __init__(
        self,
        config: SlackChannelConfig,
        client: WebClient,
        authorizer: SlackAuthorizer,
        thread_store: SlackThreadStore,
        slack_listener: SlackChannelListener | None = None,
        *,
        repo_id: str,
    ) -> None:
        self._config = config
        self._client = client
        self._authorizer = authorizer
        self._thread_store = thread_store
        self._listener = slack_listener
        self._repo_id = repo_id

    @classmethod
    def from_config(
        cls, config: SlackChannelConfig, *, repo_id: str
    ) -> SlackChannelAdapter:
        """Create an adapter with all Slack components from config.

        Args:
            config: Slack channel configuration.
            repo_id: Repository identifier.

        Returns:
            Fully configured ``SlackChannelAdapter``.
        """
        from airut.gateway.config import get_storage_dir

        client = WebClient(token=config.bot_token)
        authorizer = SlackAuthorizer(client=client, rules=config.authorized)
        state_dir = get_storage_dir(repo_id)
        thread_store = SlackThreadStore(state_dir)
        listener = SlackChannelListener(config)

        return cls(
            config=config,
            client=client,
            authorizer=authorizer,
            thread_store=thread_store,
            slack_listener=listener,
            repo_id=repo_id,
        )

    @property
    def listener(self) -> SlackChannelListener:
        """Slack channel listener for message lifecycle management.

        Raises:
            RuntimeError: If adapter was created without a listener.
        """
        if self._listener is None:
            raise RuntimeError("SlackChannelAdapter created without a listener")
        return self._listener

    def channel_context(self) -> str:
        """Return the base system prompt for the Slack channel.

        This is the standard set of instructions about the Slack
        interface, formatting, tool limitations, and directory layout
        that applies to every Slack-delivered task — both interactive
        messages and scheduled tasks.
        """
        return (
            "User is interacting with this session via Slack "
            "and will receive your last reply as a Slack message. "
            "After the reply, everything not in /workspace, /inbox, "
            "and /storage is reset. "
            "Markdown formatting (except tables) is supported "
            "in your responses. "
            "To send files back to the user, place them in the "
            "/outbox directory root (no subdirectories). "
            "Use /storage to persist files across messages.\n\n"
            "IMPORTANT: AskUserQuestion and plan mode tools "
            "(EnterPlanMode/ExitPlanMode) do not work over Slack. "
            "If you need clarification, include questions in "
            "your response text and the user will reply via Slack."
        )

    def authenticate_and_parse(
        self, raw_message: RawMessage[JsonDict]
    ) -> SlackParsedMessage:
        """Authenticate sender and parse the Slack message.

        Args:
            raw_message: ``RawMessage`` wrapping a Slack event payload.

        Returns:
            ``SlackParsedMessage`` if authenticated and authorized.

        Raises:
            AuthenticationError: If authorization fails.
        """
        payload = raw_message.content
        user_id = cast(str, payload.get("user", ""))
        text = cast(str, payload.get("text", ""))
        channel_id = cast(str, payload.get("channel", ""))
        thread_ts = cast(str, payload.get("thread_ts", ""))

        # Authorize via rules
        authorized, reason = self._authorizer.authorize(user_id)
        if not authorized:
            logger.warning(
                "Repo '%s': rejecting Slack message from %s: %s",
                self._repo_id,
                user_id,
                reason,
            )
            raise AuthenticationError(sender=user_id, reason=reason)

        # Look up existing conversation from thread mapping
        conversation_id = self._thread_store.get_conversation_id(
            channel_id, thread_ts
        )

        # Extract file metadata for deferred download
        slack_file_urls: list[tuple[str, str]] = []
        for raw_file in cast(list[JsonDict], payload.get("files", [])):
            name = cast(str, raw_file.get("name", "unnamed"))
            url = cast(
                str,
                raw_file.get("url_private_download")
                or raw_file.get("url_private", ""),
            )
            if url:
                slack_file_urls.append((name, url))

        # Build display title
        display_title = text[:_MAX_TITLE_LENGTH].split("\n")[0] if text else ""

        return SlackParsedMessage(
            sender=user_id,
            body=text,
            conversation_id=conversation_id,
            model_hint=None,
            display_title=display_title or "(no message)",
            channel_context=self.channel_context(),
            slack_channel_id=channel_id,
            slack_thread_ts=thread_ts,
            slack_file_urls=slack_file_urls,
        )

    def save_attachments(
        self, parsed: ParsedMessage, inbox_dir: Path
    ) -> list[str]:
        """Download Slack files and save to the inbox directory.

        Uses ``urllib.request`` for downloads because the Slack SDK's
        ``WebClient`` does not expose a file download method.  The SDK
        itself uses ``urllib`` internally (not ``httpx`` or ``requests``),
        so this is consistent.  Auth comes from ``WebClient.token`` to
        stay in sync with the client's credential management.

        Args:
            parsed: Parsed message (must be a ``SlackParsedMessage``).
            inbox_dir: Path to save attachments to.

        Returns:
            List of saved filenames.
        """
        if not isinstance(parsed, SlackParsedMessage):
            raise TypeError(
                f"Expected SlackParsedMessage, got {type(parsed).__name__}"
            )
        saved: list[str] = []

        for filename, url in parsed.slack_file_urls:
            try:
                if not _is_slack_file_url(url):
                    logger.warning(
                        "Skipping Slack attachment %s: URL host not in "
                        "allowed set %s",
                        filename,
                        _SLACK_FILE_HOSTS,
                    )
                    continue

                req = urllib.request.Request(
                    url,
                    headers={"Authorization": f"Bearer {self._client.token}"},
                )
                with urllib.request.urlopen(req) as resp:
                    data = resp.read(_MAX_DOWNLOAD_BYTES + 1)
                    if len(data) > _MAX_DOWNLOAD_BYTES:
                        logger.warning(
                            "Slack attachment %s exceeds %d bytes, skipping",
                            filename,
                            _MAX_DOWNLOAD_BYTES,
                        )
                        continue

                safe_name = Path(filename).name
                if not safe_name:
                    safe_name = "unnamed"
                filepath = inbox_dir / safe_name
                filepath.write_bytes(data)
                saved.append(safe_name)
                logger.info("Saved Slack attachment: %s", safe_name)

            except Exception as e:
                logger.warning(
                    "Failed to download Slack file %s: %s", filename, e
                )

        return saved

    def send_acknowledgment(
        self,
        parsed: ParsedMessage,
        conversation_id: str,
        model: str,
        dashboard_url: str | None,
    ) -> None:
        """Send acknowledgment and register thread mapping."""
        if not isinstance(parsed, SlackParsedMessage):
            raise TypeError(
                f"Expected SlackParsedMessage, got {type(parsed).__name__}"
            )

        # Register thread mapping for conversation resumption
        self._thread_store.register(
            parsed.slack_channel_id,
            parsed.slack_thread_ts,
            conversation_id,
        )

        # Build acknowledgment text (aligned with email channel)
        if dashboard_url:
            task_url = f"{dashboard_url}/conversation/{conversation_id}"
            text = (
                f"I've started working on this and will reply shortly. "
                f"See progress at {task_url}"
            )
        else:
            text = "I've started working on this and will reply shortly."

        try:
            self._client.chat_postMessage(
                channel=parsed.slack_channel_id,
                thread_ts=parsed.slack_thread_ts,
                text=text,
            )
        except SlackApiError as e:
            logger.warning(
                "Failed to send Slack acknowledgment (non-fatal): %s", e
            )

    def send_reply(
        self,
        parsed: ParsedMessage,
        conversation_id: str,
        response_text: str,
        usage_footer: str,
        outbox_files: list[Path],
    ) -> None:
        """Send reply with response text, optional files, and thread title."""
        if not isinstance(parsed, SlackParsedMessage):
            raise TypeError(
                f"Expected SlackParsedMessage, got {type(parsed).__name__}"
            )

        # Append usage footer
        body = response_text
        if usage_footer:
            body = f"{response_text}\n\n_{usage_footer}_"

        # Sanitize Markdown for Slack rendering
        body = sanitize_for_slack(body)

        # Send message(s)
        try:
            _send_long_message(
                client=self._client,
                channel=parsed.slack_channel_id,
                thread_ts=parsed.slack_thread_ts,
                text=body,
            )
        except SlackApiError as e:
            logger.error("Failed to send Slack reply: %s", e)
            raise ChannelSendError(str(e)) from e

        # Upload outbox files
        for filepath in outbox_files:
            if filepath.exists():
                try:
                    _upload_file(
                        self._client,
                        parsed.slack_channel_id,
                        parsed.slack_thread_ts,
                        filepath,
                    )
                except SlackApiError as e:
                    logger.warning(
                        "Failed to upload file %s: %s", filepath.name, e
                    )

        # Set thread title from first message
        title = parsed.display_title[:_MAX_TITLE_LENGTH]
        if title:
            try:
                self._client.assistant_threads_setTitle(
                    channel_id=parsed.slack_channel_id,
                    thread_ts=parsed.slack_thread_ts,
                    title=title,
                )
            except SlackApiError as e:
                logger.debug("Failed to set thread title: %s", e)

    def send_error(
        self,
        parsed: ParsedMessage,
        conversation_id: str | None,
        error_message: str,
    ) -> None:
        """Send error notification to the Slack thread."""
        if not isinstance(parsed, SlackParsedMessage):
            raise TypeError(
                f"Expected SlackParsedMessage, got {type(parsed).__name__}"
            )

        try:
            self._client.chat_postMessage(
                channel=parsed.slack_channel_id,
                thread_ts=parsed.slack_thread_ts,
                text=f"An error occurred: {error_message}",
            )
        except SlackApiError as e:
            logger.error("Failed to send Slack error message: %s", e)

    def send_rejection(
        self,
        parsed: ParsedMessage,
        conversation_id: str,
        reason: str,
        dashboard_url: str | None,
    ) -> None:
        """Send rejection notification to the Slack thread."""
        if not isinstance(parsed, SlackParsedMessage):
            raise TypeError(
                f"Expected SlackParsedMessage, got {type(parsed).__name__}"
            )

        text = f"Your message could not be processed.\n\nReason: {reason}"
        if dashboard_url:
            task_url = f"{dashboard_url}/conversation/{conversation_id}"
            text += f"\n\nConversation: {task_url}"
        else:
            text += f"\n\nConversation ID: {conversation_id}"

        try:
            self._client.chat_postMessage(
                channel=parsed.slack_channel_id,
                thread_ts=parsed.slack_thread_ts,
                text=text,
            )
        except SlackApiError as e:
            logger.warning("Failed to send Slack rejection (non-fatal): %s", e)

    def create_plan_streamer(
        self, parsed: ParsedMessage
    ) -> PlanStreamer | None:
        """Create a plan streamer for real-time task progress.

        Returns a ``SlackPlanStreamer`` that displays ``TodoWrite``
        progress in the Slack thread via message updates.

        Args:
            parsed: Parsed message (must be a ``SlackParsedMessage``).

        Returns:
            A ``SlackPlanStreamer``, or ``None`` if the parsed message
            is not a ``SlackParsedMessage``.
        """
        if not isinstance(parsed, SlackParsedMessage):
            return None
        return SlackPlanStreamer(
            client=self._client,
            channel=parsed.slack_channel_id,
            thread_ts=parsed.slack_thread_ts,
        )

    def cleanup_conversations(self, active_conversation_ids: set[str]) -> None:
        """Remove thread mappings for conversations no longer active."""
        removed = self._thread_store.retain_only(active_conversation_ids)
        if removed:
            logger.info(
                "Repo '%s': pruned %d stale thread mappings",
                self._repo_id,
                removed,
            )


def _is_slack_file_url(url: str) -> bool:
    """Check whether a URL points to a known Slack file-hosting domain.

    Only HTTPS URLs on :data:`_SLACK_FILE_HOSTS` are accepted.  This
    prevents the bot token from being sent to arbitrary hosts if the
    event payload contains unexpected URLs.

    Args:
        url: URL to validate.

    Returns:
        True if the URL is a valid Slack file URL.
    """
    try:
        parsed = urllib.parse.urlparse(url)
    except ValueError:
        return False
    return parsed.scheme == "https" and parsed.hostname in _SLACK_FILE_HOSTS


def _split_blocks(text: str) -> list[str]:
    """Split text into chunks suitable for ``markdown`` blocks.

    Splits at paragraph boundaries first, then at line boundaries
    if a paragraph exceeds the block limit.

    Args:
        text: Full response text.

    Returns:
        List of text chunks, each within ``_MAX_BLOCK_CHARS``.
    """
    if len(text) <= _MAX_BLOCK_CHARS:
        return [text]

    blocks: list[str] = []
    current = ""

    for paragraph in text.split("\n\n"):
        candidate = f"{current}\n\n{paragraph}" if current else paragraph
        if len(candidate) <= _MAX_BLOCK_CHARS:
            current = candidate
        else:
            if current:
                blocks.append(current)
            # If single paragraph exceeds limit, split by lines
            if len(paragraph) > _MAX_BLOCK_CHARS:
                lines = paragraph.split("\n")
                current = ""
                for line in lines:
                    candidate = f"{current}\n{line}" if current else line
                    if len(candidate) <= _MAX_BLOCK_CHARS:
                        current = candidate
                    else:
                        if current:
                            blocks.append(current)
                        current = line[:_MAX_BLOCK_CHARS] + "\n[truncated]"
            else:
                current = paragraph

    if current:
        blocks.append(current)

    return blocks


def _is_invalid_blocks(error: SlackApiError) -> bool:
    """Check whether a Slack API error is an ``invalid_blocks`` rejection."""
    resp = error.response
    return isinstance(resp, dict) and resp.get("error") == "invalid_blocks"


def _post_with_fallback(
    client: WebClient,
    channel: str,
    thread_ts: str,
    text: str,
    blocks: list[dict[str, str]],
) -> None:
    """Post a message with ``markdown`` blocks, falling back to plain text.

    If Slack rejects the blocks payload with ``invalid_blocks``, retry the
    same message as plain text (mrkdwn) without blocks.  Plain-text messages
    support up to 40 000 characters; content beyond that is truncated by
    Slack, which is an acceptable degradation compared to a complete failure.

    Args:
        client: Slack ``WebClient``.
        channel: Channel ID.
        thread_ts: Thread timestamp.
        text: Plain-text body (used as mrkdwn fallback).
        blocks: Block Kit ``markdown`` blocks to try first.
    """
    try:
        client.chat_postMessage(
            channel=channel,
            thread_ts=thread_ts,
            blocks=blocks,
            text=text[:200],  # fallback text for notifications
        )
    except SlackApiError as exc:
        if not _is_invalid_blocks(exc):
            raise
        logger.warning(
            "Slack rejected blocks payload (invalid_blocks), "
            "retrying as plain text"
        )
        client.chat_postMessage(
            channel=channel,
            thread_ts=thread_ts,
            text=text[:_MAX_TEXT_CHARS],
        )


def _send_long_message(
    client: WebClient,
    channel: str,
    thread_ts: str,
    text: str,
) -> None:
    """Send a potentially long message, splitting as needed.

    Strategy:
    1. Split into multiple ``markdown`` blocks within one message.
    2. If total exceeds ~13K chars, split into multiple messages.
    3. If extremely long, upload as file attachment.

    Each ``chat.postMessage`` call uses :func:`_post_with_fallback` so that
    an ``invalid_blocks`` error triggers a plain-text retry rather than a
    hard failure.

    Args:
        client: Slack ``WebClient``.
        channel: Channel ID.
        thread_ts: Thread timestamp.
        text: Full response text.
    """
    blocks = _split_blocks(text)

    if len(text) <= _MAX_MESSAGE_CHARS:
        # Single message with multiple blocks
        block_kit = [{"type": "markdown", "text": block} for block in blocks]
        _post_with_fallback(client, channel, thread_ts, text, block_kit)
    elif len(text) <= _MAX_MESSAGE_CHARS * 5:
        # Multiple messages
        message_blocks: list[list[tuple[str, dict[str, str]]]] = []
        current_message: list[tuple[str, dict[str, str]]] = []
        current_length = 0

        for block in blocks:
            if current_length + len(block) > _MAX_MESSAGE_CHARS:
                if current_message:
                    message_blocks.append(current_message)
                current_message = []
                current_length = 0
            current_message.append((block, {"type": "markdown", "text": block}))
            current_length += len(block)

        if current_message:
            message_blocks.append(current_message)

        for msg_entries in message_blocks:
            plain = "\n\n".join(raw for raw, _ in msg_entries)
            kit = [blk for _, blk in msg_entries]
            _post_with_fallback(client, channel, thread_ts, plain, kit)
    else:
        # Fallback: upload as file
        client.files_upload_v2(
            channel=channel,
            thread_ts=thread_ts,
            content=text,
            filename="response.md",
            title="Response",
        )


def _upload_file(
    client: WebClient,
    channel: str,
    thread_ts: str,
    filepath: Path,
) -> None:
    """Upload a file to a Slack thread.

    Args:
        client: Slack ``WebClient``.
        channel: Channel ID.
        thread_ts: Thread timestamp.
        filepath: Path to the file to upload.
    """
    client.files_upload_v2(
        channel=channel,
        thread_ts=thread_ts,
        file=str(filepath),
        filename=filepath.name,
    )
