# 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."""

import logging
import time
import uuid
from datetime import datetime

from gi.repository import GLib

from meshy.models import (
    Contact, Message, ChannelMessage, MessageStatus,
)
from meshy.protocol import (
    build_send_text_msg, build_send_channel_text_msg,
    build_reset_path, build_set_flood_scope,
    STATS_TYPE_RADIO,
)

log = logging.getLogger(__name__)

PATH_RETRIES = 4
FLOOD_RETRIES = 3


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

    def __init__(self, win):
        self._win = win
        self.pending_acks: dict[str, 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_timeouts: dict[str, int] = {}
        self.last_rx_time: float = 0.0
        self.prev_total_air_secs: int = 0
        self.last_airtime_bump: float = 0.0

    def cleanup_on_disconnect(self):
        self.pending_acks.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.sent_msg_queue.clear()

    def send_message(self, contact: Contact, text: str):
        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
        self.retry_context[msg_id] = {
            'contact': contact,
            'text': text,
            'timestamp': timestamp,
            'attempt': 0,
            'started_with_path': has_path,
        }

        def _after_render():
            self._win._storage.save_message(contact.public_key_hex, message)
            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 ack_key, tracked_id in list(self.pending_acks.items()):
            if tracked_id == msg_id:
                self.pending_acks.pop(ack_key)
        for ack_key, tracked_id in list(self.grace_acks.items()):
            if tracked_id == msg_id:
                self.grace_acks.pop(ack_key)
        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
        self.retry_context[msg_id] = {
            'contact': contact,
            'text': message.text,
            'timestamp': timestamp,
            'attempt': 0,
            'started_with_path': has_path,
        }
        self._send_message_attempt(msg_id)

    def send_channel_message(self, channel_index: int, text: str):
        channel = next((ch for ch in self._win._channels if ch.index == channel_index), None)
        region = channel.region if channel else ''
        timestamp = int(datetime.now().timestamp())
        frame = build_send_channel_text_msg(channel_index, text, timestamp=timestamp)

        if region:
            self._win.send_frame(build_set_flood_scope(region))
            self._win.send_frame(frame)
            GLib.timeout_add(300, self._win._apply_default_scope)
        else:
            self._win.send_frame(frame)
        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:
        """Resend a channel message with a fresh timestamp.

        Returns the new timestamp used for sending.
        """
        channel = next((ch for ch in self._win._channels if ch.index == channel_index), None)
        region = channel.region if channel else ''
        new_ts = int(datetime.now().timestamp())
        frame = build_send_channel_text_msg(channel_index, text, timestamp=new_ts)

        if region:
            self._win.send_frame(build_set_flood_scope(region))
            self._win.send_frame(frame)
            GLib.timeout_add(300, self._win._apply_default_scope)
        else:
            self._win.send_frame(frame)
        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:
        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 _send_message_attempt(self, msg_id: str):
        ctx = self.retry_context.get(msg_id)
        if not ctx:
            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 _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

        if ctx.get('started_with_path') and ctx['attempt'] >= PATH_RETRIES:
            if contact.path_length >= 0 and contact.path_length != 0xFF:
                self._win.send_frame(build_reset_path(contact.public_key))
                contact.path_length = -1
                contact.path = b''

        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 _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)
        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')
            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 = [k for k, v in self.pending_acks.items() if v == msg_id]
            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
            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:
                contact_key = contact.public_key_hex
                current = self._win._contacts_by_key.get(contact_key)
                if current and current.path_length >= 0 and started_with_path:
                    self._win.send_frame(build_reset_path(current.public_key))
                    current.path_length = -1
                    current.path = b''
                    self._win._contacts_view.update_contacts(self._win._contacts)
                    self._win._refresh_chat_header()
                GLib.timeout_add(500, lambda: self._win._frame_handler.send_get_contacts() or False)

        return False

    def _on_grace_expired(self, msg_id: str) -> bool:
        for k in [k for k, v in self.grace_acks.items() if v == msg_id]:
            self.grace_acks.pop(k, None)
        self.grace_timeouts.pop(msg_id, None)
        return False
