# Copyright 2026, Jiri Eischmann <jiri@eischmann.cz> and the meshy contributors
# SPDX-License-Identifier: GPL-3.0-or-later

"""Message sending, retry, and delivery tracking extracted from MeshyWindow."""

from __future__ import annotations

import logging
import time
import uuid
from datetime import datetime

from gi.repository import GLib
from meshy.models import (
    ChannelMessage,
    Contact,
    Message,
    MessageStatus,
)
from meshy.path_history import PathHistoryService
from meshy.protocol import (
    STATS_TYPE_RADIO,
    build_reset_path,
    build_send_channel_text_msg,
    build_send_text_msg,
    build_set_flood_scope,
)

log = logging.getLogger(__name__)

PATH_RETRIES = 4
FLOOD_RETRIES = 3
MAX_IN_FLIGHT = 6


class MessageController:
    """Owns message send/retry state and delivery tracking."""

    def __init__(self, win):
        self._win = win
        self._path_history: PathHistoryService | None = None
        self.pending_acks: dict[str, str] = {}
        self._acks_by_msg: dict[str, set[str]] = {}
        self.retry_context: dict[str, dict] = {}
        self.sent_msg_queue: list[str] = []
        self.pending_timeouts: dict[str, int] = {}
        self.grace_acks: dict[str, str] = {}
        self._grace_by_msg: dict[str, set[str]] = {}
        self.grace_timeouts: dict[str, int] = {}
        self.pending_flood_attributions: dict[str, list[tuple[int, bool]]] = {}
        self.grace_contexts: dict[str, dict] = {}
        self.last_rx_time: float = 0.0
        self.prev_total_air_secs: int = 0
        self.last_airtime_bump: float = 0.0

    @property
    def path_history(self) -> PathHistoryService:
        if self._path_history is None:
            self._path_history = PathHistoryService(self._win._storage)
        return self._path_history

    @property
    def _device_hash_size(self) -> int:
        return getattr(getattr(self._win, "_device_info", None), "path_hash_mode", 0) + 1

    @staticmethod
    def _determine_strategy(contact: Contact, route_override: str | None) -> str:
        if route_override == "direct":
            return "override_direct"
        if route_override == "flood":
            return "override_flood"
        if contact.force_flood:
            return "force_flood"
        if contact.smart_route_selection:
            return "smart"
        return "traditional"

    def cleanup_on_disconnect(self):
        self._path_history = None
        self.pending_acks.clear()
        self._acks_by_msg.clear()
        self.retry_context.clear()
        for tid in self.pending_timeouts.values():
            GLib.source_remove(tid)
        self.pending_timeouts.clear()
        for tid in self.grace_timeouts.values():
            GLib.source_remove(tid)
        self.grace_timeouts.clear()
        self.grace_acks.clear()
        self._grace_by_msg.clear()
        self.grace_contexts.clear()
        self.pending_flood_attributions.clear()
        self.sent_msg_queue.clear()

    def send_message(self, contact: Contact, text: str, route_override: str | None = None):
        msg_id = str(uuid.uuid4())
        timestamp = int(datetime.now().timestamp())

        message = Message(
            sender_key=contact.public_key,
            text=text,
            timestamp=datetime.fromtimestamp(timestamp),
            is_outgoing=True,
            status=MessageStatus.PENDING,
            message_id=msg_id,
        )
        self._win._chat_view.on_message_sent(message)

        has_path = contact.path_length >= 0 and contact.path_length != 0xFF
        strategy = self._determine_strategy(contact, route_override)
        self.retry_context[msg_id] = {
            "contact": contact,
            "text": text,
            "timestamp": timestamp,
            "attempt": 0,
            "started_with_path": has_path,
            "strategy": strategy,
            "has_path_history": (
                strategy == "smart" and self.path_history.has_paths(contact.public_key_hex)
            ),
            "route_override": route_override,
        }

        def _after_render():
            self._win._storage.save_message(contact.public_key_hex, message)
            contact.last_message_at = message.timestamp
            self._win._frame_handler.schedule_contacts_refresh()
            self._send_message_attempt(msg_id)
            return False

        GLib.idle_add(_after_render)

    def resend_message(self, contact: Contact, message: Message):
        msg_id = message.message_id
        timestamp = int(message.timestamp.timestamp())

        if msg_id in self.pending_timeouts:
            GLib.source_remove(self.pending_timeouts.pop(msg_id))
        for k in self._acks_by_msg.pop(msg_id, set()):
            self.pending_acks.pop(k, None)
        for k in self._grace_by_msg.pop(msg_id, set()):
            self.grace_acks.pop(k, None)
        if msg_id in self.grace_timeouts:
            GLib.source_remove(self.grace_timeouts.pop(msg_id))
        if msg_id in self.sent_msg_queue:
            self.sent_msg_queue.remove(msg_id)

        self._win._storage.update_message_status(msg_id, MessageStatus.PENDING)
        self._win._chat_view.update_message_status(msg_id, MessageStatus.PENDING)

        has_path = contact.path_length >= 0 and contact.path_length != 0xFF
        strategy = self._determine_strategy(contact, None)
        self.retry_context[msg_id] = {
            "contact": contact,
            "text": message.text,
            "timestamp": timestamp,
            "attempt": 0,
            "started_with_path": has_path,
            "strategy": strategy,
            "has_path_history": (
                strategy == "smart" and self.path_history.has_paths(contact.public_key_hex)
            ),
        }
        self._send_message_attempt(msg_id)

    def _send_channel_frame(self, channel_index: int, frame: bytes):
        channel = next((ch for ch in self._win._channels if ch.index == channel_index), None)
        region = channel.region if channel else ""
        if region:
            self._win.send_frame(build_set_flood_scope(region))
            self._win.send_frame(frame)
            GLib.timeout_add(300, self._win._reset_session_scope)
        else:
            self._win.send_frame(frame)

    def send_channel_message(self, channel_index: int, text: str):
        timestamp = int(datetime.now().timestamp())
        self._send_channel_frame(
            channel_index, build_send_channel_text_msg(channel_index, text, timestamp=timestamp)
        )
        name = self._win._self_info.get("name", _("Me"))
        msg = ChannelMessage(
            channel_index=channel_index,
            sender_name=name,
            text=text,
            timestamp=datetime.fromtimestamp(timestamp),
            is_outgoing=True,
        )
        self._win._storage.save_channel_message(msg)
        self._win._channels_view.on_channel_message_sent(msg, timestamp)

    def resend_channel_message(self, channel_index: int, text: str) -> int:
        new_ts = int(datetime.now().timestamp())
        self._send_channel_frame(
            channel_index, build_send_channel_text_msg(channel_index, text, timestamp=new_ts)
        )
        return new_ts

    def delete_message(self, message_id: str):
        self._win._storage.delete_message(message_id)

    def delete_channel_message(self, channel_index: int, timestamp: float, text: str):
        self._win._storage.delete_channel_message(channel_index, timestamp, text)

    def max_retries_for(self, ctx: dict) -> int:
        strategy = ctx.get("strategy", "traditional")
        if strategy == "override_direct":
            return 1
        if strategy in ("override_flood", "force_flood"):
            return FLOOD_RETRIES
        if strategy == "smart":
            return PATH_RETRIES + 1 if ctx.get("has_path_history") else FLOOD_RETRIES
        if ctx.get("started_with_path"):
            return PATH_RETRIES + 1
        return FLOOD_RETRIES

    def send_detail(self, msg_id: str, is_flood: bool) -> str:
        ctx = self.retry_context.get(msg_id)
        contact = ctx.get("contact") if ctx else None

        if is_flood:
            route = _("flood")
        elif contact and contact.path_length == 0:
            route = _("direct")
        else:
            route = _("path")

        if not ctx:
            return _("– {route}").format(route=route)
        attempt = ctx["attempt"] + 1
        max_retries = self.max_retries_for(ctx)
        if attempt <= 1:
            return _("– {route}").format(route=route)
        return _("({attempt}/{total}) – {route}").format(
            attempt=attempt, total=max_retries, route=route
        )

    def _airtime_busy_fraction(self) -> float:
        if self.last_airtime_bump == 0.0:
            return 0.0
        ms = (time.monotonic() - self.last_airtime_bump) * 1000
        if ms >= 8000:
            return 0.0
        return 1.0 - ms / 8000

    def _radio_quiet_delay_ms(self) -> int:
        elapsed = time.monotonic() - self.last_rx_time
        if elapsed > 3.0:
            return 0

        radio = self._win._device_stats.get(STATS_TYPE_RADIO)
        if not radio:
            return 1000

        noise = radio.get("noise_floor", -118)
        noise_severity = max(0.0, min(1.0, (noise + 118) / 30.0))

        snr = radio.get("last_snr", 12.0)
        snr_severity = max(0.0, min(1.0, (12.0 - snr) / 14.0))

        air_busy = self._airtime_busy_fraction()
        severity = max(noise_severity, snr_severity) * 0.82 + air_busy * 0.18

        return int(500 + severity * 7500)

    def _in_flight_count(self) -> int:
        return len(self.sent_msg_queue) + len(self._acks_by_msg)

    def _send_message_attempt(self, msg_id: str):
        ctx = self.retry_context.get(msg_id)
        if not ctx:
            return

        if self._in_flight_count() >= MAX_IN_FLIGHT:
            GLib.timeout_add(500, lambda: self._send_message_attempt(msg_id) or False)
            return

        delay = self._radio_quiet_delay_ms()
        if delay > 0:
            self._win._chat_view.update_message_status(
                msg_id,
                MessageStatus.PENDING,
                _("waiting {:.1f}s (radio busy)").format(delay / 1000),
            )
            GLib.timeout_add(delay, self._do_send_message, msg_id)
        else:
            self._do_send_message(msg_id)

    def _reset_to_flood(self, contact: Contact):
        if contact.path_length >= 0:
            self._win.send_frame(build_reset_path(contact.public_key))
            contact.path_length = -1
            contact.path = b""

    def _restore_original_path(self, ctx: dict):
        orig = ctx.get("original_path")
        if not orig:
            return
        contact = ctx["contact"]
        pl = orig["path_length"]
        if pl < 0:
            self._reset_to_flood(contact)
        else:
            hash_mode = orig["path_hash_size"] - 1
            path_len_byte = (hash_mode << 6) | (pl & 0x3F)
            self._win.set_contact_path(contact, path_len_byte, orig["path"])

    def _apply_traditional_route(self, ctx: dict, contact: Contact):
        if (
            contact.auto_reset_path
            and ctx.get("started_with_path")
            and ctx["attempt"] >= PATH_RETRIES
        ):
            self._reset_to_flood(contact)

    def _apply_smart_route(self, ctx: dict, contact: Contact):
        if not ctx.get("has_path_history") or ctx["attempt"] >= PATH_RETRIES:
            self._reset_to_flood(contact)
            return

        tried = ctx.get("paths_tried", [])
        best = self.path_history.select_path(
            contact.public_key_hex,
            exclude_paths=tried,
        )
        if best is None and tried:
            ctx["paths_tried"] = []
            best = self.path_history.select_path(contact.public_key_hex)
        if best:
            path_bytes_raw = bytes.fromhex(best.path_bytes)
            hash_mode = best.path_hash_size - 1
            path_len_byte = (hash_mode << 6) | (best.hop_count & 0x3F)
            if contact.path.hex() != best.path_bytes or contact.path_length != best.hop_count:
                self._win.set_contact_path(contact, path_len_byte, path_bytes_raw)
        else:
            self._reset_to_flood(contact)

    def _do_send_message(self, msg_id: str) -> bool:
        ctx = self.retry_context.get(msg_id)
        if not ctx:
            return False

        contact_key = ctx["contact"].public_key_hex
        contact = self._win._contacts_by_key.get(contact_key, ctx["contact"])
        ctx["contact"] = contact

        strategy = ctx["strategy"]
        if strategy in ("override_direct", "override_flood", "smart", "traditional"):
            if "original_path" not in ctx:
                ctx["original_path"] = {
                    "path_length": contact.path_length,
                    "path": contact.path,
                    "path_hash_size": contact.path_hash_size,
                }

        if strategy == "override_direct":
            self._win.set_contact_path(contact, 0, b"")
        elif strategy == "override_flood" or strategy == "force_flood":
            self._reset_to_flood(contact)
        elif strategy == "smart":
            try:
                self._apply_smart_route(ctx, contact)
            except Exception:
                log.exception("Smart route selection failed, sending with current path")
        else:
            self._apply_traditional_route(ctx, contact)

        ctx["path_used"] = {
            "path_length": contact.path_length,
            "path_bytes": contact.path.hex(),
            "path_hash_size": contact.path_hash_size,
            "hop_count": max(contact.path_length, 0),
        }
        ctx.setdefault("attempt_paths", {})[ctx["attempt"]] = ctx["path_used"]
        ctx.setdefault("paths_tried", []).append(contact.path.hex())

        self.sent_msg_queue.append(msg_id)
        frame = build_send_text_msg(
            contact.public_key,
            ctx["text"],
            attempt=ctx["attempt"],
            timestamp=ctx["timestamp"],
        )
        self._win.send_frame(frame)
        GLib.timeout_add(10000, self._on_sent_pending_timeout, msg_id)
        return False

    def register_ack_key(self, msg_id: str, ack_key: str):
        ctx = self.retry_context.get(msg_id)
        if ctx:
            ctx.setdefault("ack_paths", {})[ack_key] = ctx.get("attempt_paths", {}).get(
                ctx["attempt"]
            )

    def _on_sent_pending_timeout(self, msg_id: str) -> bool:
        if msg_id in self.sent_msg_queue:
            log.warning(f"Message {msg_id[:8]} stuck in sent queue, forcing retry")
            self.sent_msg_queue.remove(msg_id)
            self._on_message_timeout(msg_id, "")
        return False

    def _on_message_timeout(self, msg_id: str, ack_key: str) -> bool:
        if msg_id in self.pending_timeouts:
            self.pending_timeouts.pop(msg_id)

        ctx = self.retry_context.get(msg_id)
        if ctx:
            self.record_path_result(msg_id, success=False, ack_key=ack_key)
        max_retries = self.max_retries_for(ctx) if ctx else 0
        if ctx and ctx["attempt"] < max_retries - 1:
            ctx["attempt"] += 1
            backoff_ms = 1000 * (1 << (ctx["attempt"] - 1))
            detail = "({current}/{total})".format(current=ctx["attempt"] + 1, total=max_retries)
            self._win._storage.update_message_status(msg_id, MessageStatus.PENDING)
            self._win._chat_view.update_message_status(msg_id, MessageStatus.PENDING, detail)
            GLib.timeout_add(backoff_ms, lambda: self._send_message_attempt(msg_id) or False)
        elif ctx:
            attempt = ctx["attempt"] + 1
            contact = ctx.get("contact")
            started_with_path = ctx.get("started_with_path")
            strategy = ctx.get("strategy", "traditional")
            self._restore_original_path(ctx)
            self.retry_context.pop(msg_id, None)
            self._win._storage.update_message_status(msg_id, MessageStatus.FAILED)
            self._win._chat_view.update_message_status(
                msg_id, MessageStatus.FAILED, f"{attempt}/{max_retries}"
            )
            # Move all ack_keys for this message to grace_acks
            all_ack_keys = list(self._acks_by_msg.pop(msg_id, set()))
            if ack_key and ack_key != "00000000" and ack_key not in all_ack_keys:
                all_ack_keys.append(ack_key)
            for k in all_ack_keys:
                self.pending_acks.pop(k, None)
                self.grace_acks[k] = msg_id
                self._grace_by_msg.setdefault(msg_id, set()).add(k)
            if all_ack_keys:
                grace_id = GLib.timeout_add(30000, self._on_grace_expired, msg_id)
                self.grace_timeouts[msg_id] = grace_id
                if contact:
                    self.grace_contexts[msg_id] = {
                        "contact_key": contact.public_key_hex,
                        "ack_paths": ctx.get("ack_paths", {}),
                        "path_used": ctx.get("path_used"),
                    }
            if strategy == "traditional" and contact:
                contact_key = contact.public_key_hex
                current = self._win._contacts_by_key.get(contact_key)
                if current and current.auto_reset_path and started_with_path:
                    self._reset_to_flood(current)
                    self._win._contacts_view.update_contacts(self._win._contacts)
                    self._win._refresh_chat_header()
            if contact:
                GLib.timeout_add(500, lambda: self._win._frame_handler.send_get_contacts() or False)

        return False

    def record_path_result(
        self, msg_id: str, success: bool, trip_ms: int = 0, ack_key: str | None = None
    ):
        ctx = self.retry_context.get(msg_id)
        if not ctx:
            return
        path_used = None
        if ack_key:
            path_used = ctx.get("ack_paths", {}).get(ack_key)
        if not path_used:
            path_used = ctx.get("path_used")
        if not path_used or path_used["path_length"] < 0:
            # Flood was used - if successful, wait for firmware to set path,
            # then record via refetch callback
            return
        self.path_history.record_result(
            ctx["contact"].public_key_hex,
            path_used["path_bytes"],
            self._device_hash_size,
            path_used["hop_count"],
            success=success,
            trip_ms=trip_ms if success else None,
        )

    GRACE_WEIGHT_INCREMENT = 0.2

    def record_flood_attribution(
        self,
        contact_key: str,
        trip_ms: int = 0,
        late: bool = False,
    ):
        """Credit the contact's firmware-discovered path after a flood success."""
        fresh = self._win._contacts_by_key.get(contact_key)
        if not fresh or fresh.path_length < 0:
            return
        log.debug(
            "Flood attribution%s: crediting path %s for %s (trip=%dms)",
            " (late)" if late else "",
            fresh.path.hex(),
            contact_key[:8],
            trip_ms,
        )
        self.path_history.record_result(
            contact_key,
            fresh.path.hex(),
            self._device_hash_size,
            fresh.path_length,
            success=True,
            trip_ms=trip_ms,
            weight_increment=self.GRACE_WEIGHT_INCREMENT if late else None,
        )

    def _on_grace_expired(self, msg_id: str) -> bool:
        for k in self._grace_by_msg.pop(msg_id, set()):
            self.grace_acks.pop(k, None)
        self.grace_timeouts.pop(msg_id, None)
        self.grace_contexts.pop(msg_id, None)
        return False
