"""Docker Console: container logs dialog (opened from each container row).

Snapshot / in-pane follow, match navigation, ring buffer. Not a top-level tab —
use the Logs button on a container row.
"""

from __future__ import annotations

from collections import deque
from gettext import gettext as _
from typing import Any, List, Optional

import gi

gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import GLib, Gio, Gtk, Gdk, Pango, Adw  # noqa: E402

from . import widgets as w  # noqa: E402

_DEFAULT_LOG_TAIL = 200
_DEFAULT_MAX_LOG_LINES = 2000


class LogsTabMixin:
    """Container logs dialog: snapshot or Follow stream, match navigation."""

    def _ensure_logs_window(self) -> None:
        """Build the logs dialog once (content reused for every container)."""
        if getattr(self, "_logs_window", None) is not None:
            return
        win = Adw.Window()
        win.set_title(_("Container logs"))
        win.set_default_size(780, 560)
        win.set_modal(True)
        parent = self._window()
        if parent is not None:
            win.set_transient_for(parent)
        win.connect("close-request", self._on_logs_window_close)

        toolbar = Adw.ToolbarView()
        header = Adw.HeaderBar()
        self._logs_title = Gtk.Label(label=_("Logs"))
        self._logs_title.add_css_class("heading")
        header.set_title_widget(self._logs_title)
        # Rely on the header bar's native window close; a packed Close button
        # would duplicate it.
        toolbar.add_top_bar(header)
        toolbar.set_content(self._build_logs_section())
        win.set_content(toolbar)
        self._logs_window = win

    def _build_logs_section(self) -> Gtk.Widget:
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
        box.set_margin_top(8)
        box.set_margin_bottom(8)
        box.set_margin_start(12)
        box.set_margin_end(12)

        toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        self._logs_target_label = Gtk.Label(label="", xalign=0)
        self._logs_target_label.set_hexpand(True)
        self._logs_target_label.add_css_class("dim-label")
        toolbar.append(self._logs_target_label)

        toolbar.append(Gtk.Label(label=_("Tail:")))
        self._tail_spin = Gtk.SpinButton.new_with_range(10, 5000, 50)
        self._tail_spin.set_value(self._log_tail_setting())
        toolbar.append(self._tail_spin)

        self._ts_switch = Gtk.Switch()
        self._ts_switch.set_tooltip_text(_("Show timestamps (-t)"))
        self._ts_switch.set_valign(Gtk.Align.CENTER)
        toolbar.append(Gtk.Label(label=_("Timestamps")))
        toolbar.append(self._ts_switch)
        box.append(toolbar)

        actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        load = Gtk.Button(label=_("Load"))
        load.connect("clicked", lambda _b: self._reload_logs())
        actions.append(load)
        self._logs_follow = Gtk.ToggleButton(label=_("Follow"))
        self._logs_follow.set_tooltip_text(
            _("Stream new log lines into this view (docker logs -f)"))
        self._logs_follow.connect("toggled", self._on_logs_follow_toggled)
        actions.append(self._logs_follow)
        follow_term = Gtk.Button(label=_("Follow in terminal"))
        follow_term.connect("clicked", lambda _b: self._follow_logs_selected())
        actions.append(follow_term)
        copy = Gtk.Button(icon_name="edit-copy-symbolic")
        copy.set_tooltip_text(_("Copy logs"))
        copy.connect("clicked", lambda _b: self._copy_logs())
        actions.append(copy)
        save = Gtk.Button(icon_name="document-save-symbolic")
        save.set_tooltip_text(_("Save logs…"))
        save.connect("clicked", lambda _b: self._save_logs())
        actions.append(save)
        clear = Gtk.Button(icon_name="edit-clear-all-symbolic")
        clear.set_tooltip_text(_("Clear"))
        clear.connect("clicked", lambda _b: self._clear_logs())
        actions.append(clear)
        box.append(actions)

        filters = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        self._logs_search = Gtk.SearchEntry()
        self._logs_search.set_placeholder_text(_("Search logs…"))
        self._logs_search.set_hexpand(True)
        self._logs_search.connect("search-changed", lambda _e: self._on_logs_search_changed())
        self._logs_search.connect("activate", lambda _e: self._logs_match_next())
        filters.append(self._logs_search)

        self._logs_case = Gtk.ToggleButton(label=_("Aa"))
        self._logs_case.set_tooltip_text(_("Case-sensitive search"))
        self._logs_case.connect("toggled", lambda _b: self._on_logs_search_changed())
        filters.append(self._logs_case)

        self._logs_highlight_mode = Gtk.ToggleButton(label=_("Highlight"))
        self._logs_highlight_mode.set_tooltip_text(
            _("Highlight matches instead of filtering lines out"))
        self._logs_highlight_mode.set_active(True)
        self._logs_highlight_mode.connect("toggled", lambda _b: self._apply_log_filter())
        filters.append(self._logs_highlight_mode)

        prev_btn = Gtk.Button(icon_name="go-up-symbolic")
        prev_btn.set_tooltip_text(_("Previous match"))
        prev_btn.connect("clicked", lambda _b: self._logs_match_prev())
        filters.append(prev_btn)
        next_btn = Gtk.Button(icon_name="go-down-symbolic")
        next_btn.set_tooltip_text(_("Next match"))
        next_btn.connect("clicked", lambda _b: self._logs_match_next())
        filters.append(next_btn)
        self._logs_match_label = Gtk.Label(label="")
        self._logs_match_label.add_css_class("dim-label")
        self._logs_match_label.add_css_class("caption")
        filters.append(self._logs_match_label)

        self._logs_errors_only = Gtk.ToggleButton(label=_("Errors only"))
        self._logs_errors_only.set_tooltip_text(
            _("Show only lines mentioning error/warn/fail/fatal"))
        self._logs_errors_only.connect("toggled", lambda _b: self._apply_log_filter())
        filters.append(self._logs_errors_only)
        self._logs_autoscroll = Gtk.ToggleButton(label=_("Auto-scroll"))
        self._logs_autoscroll.set_active(True)
        filters.append(self._logs_autoscroll)
        box.append(filters)

        scroller = Gtk.ScrolledWindow()
        scroller.set_vexpand(True)
        self._logs_view = Gtk.TextView()
        self._logs_view.set_editable(False)
        self._logs_view.set_monospace(True)
        self._logs_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
        self._logs_buffer = self._logs_view.get_buffer()
        self._logs_tag_match = self._logs_buffer.create_tag(
            "log-match", background="#f9e2af", foreground="#1e1e2e")
        self._logs_tag_current = self._logs_buffer.create_tag(
            "log-match-current", background="#89b4fa", foreground="#1e1e2e",
            weight=Pango.Weight.BOLD)
        scroller.set_child(self._logs_view)

        overlay = Gtk.Overlay()
        overlay.set_vexpand(True)
        overlay.set_child(scroller)
        self._logs_pulse = self._make_docker_mark(56)
        self._logs_pulse.set_visible(False)
        overlay.add_overlay(self._logs_pulse)
        box.append(overlay)

        self._logs_lines: deque = deque(maxlen=self._max_log_lines_setting())
        self._logs_raw = ""
        self._logs_stream = None
        self._logs_stream_gen = 0
        self._logs_match_offsets: List[int] = []
        self._logs_match_index = -1
        self._logs_pending_lines: List[str] = []
        self._logs_flush_source: Optional[int] = None
        self._syncing_logs_follow = False
        return box

    def _open_container_logs(self, cid: str, name: str, *, follow: bool = False) -> None:
        """Open the logs dialog for ``cid`` (from a container row button)."""
        self._ensure_logs_window()
        self._set_selected_container(cid, name, source="logs")
        self._update_logs_target_label()
        self._logs_lines.clear()
        self._logs_raw = ""
        self._logs_buffer.set_text("", 0)
        self._logs_window.set_title(_("Logs — {name}").format(name=name))
        self._logs_title.set_label(_("Logs — {name}").format(name=name))
        parent = self._window()
        if parent is not None:
            self._logs_window.set_transient_for(parent)
        self._logs_window.present()
        if follow:
            if not self._logs_follow.get_active():
                self._logs_follow.set_active(True)
            else:
                self._start_logs_follow()
        else:
            self._stop_logs_follow()
            self._reload_logs()

    def _close_logs_window(self) -> None:
        self._stop_logs_follow()
        if getattr(self, "_logs_window", None) is not None:
            self._logs_window.hide()

    def _on_logs_window_close(self, *_a) -> bool:
        self._stop_logs_follow()
        if getattr(self, "_logs_window", None) is not None:
            self._logs_window.hide()
        return True  # don't destroy — reuse next time

    def _update_logs_target_label(self) -> None:
        if not hasattr(self, "_logs_target_label"):
            return
        name = self._selected_container_name()
        cid = self._selected_container_id() or ""
        short = cid[:12] if cid else ""
        self._logs_target_label.set_label(
            f"{name}" + (f"  ({short})" if short else ""))

    def _log_tail_setting(self) -> int:
        try:
            return max(10, int(self.ctx.settings.get("log_tail", _DEFAULT_LOG_TAIL)))
        except (TypeError, ValueError):
            return _DEFAULT_LOG_TAIL

    def _max_log_lines_setting(self) -> int:
        try:
            return max(100, int(self.ctx.settings.get(
                "max_log_lines", _DEFAULT_MAX_LOG_LINES)))
        except (TypeError, ValueError):
            return _DEFAULT_MAX_LOG_LINES

    def _resize_logs_ring(self) -> None:
        maxlen = self._max_log_lines_setting()
        if self._logs_lines.maxlen == maxlen:
            return
        self._logs_lines = deque(self._logs_lines, maxlen=maxlen)

    def _stop_logs_follow(self) -> None:
        self._logs_stream_gen += 1
        handle = getattr(self, "_logs_stream", None)
        self._logs_stream = None
        if handle is not None:
            try:
                handle.stop()
            except Exception:  # noqa: BLE001
                pass
        if getattr(self, "_logs_flush_source", None) is not None:
            GLib.source_remove(self._logs_flush_source)
            self._logs_flush_source = None
        self._logs_pending_lines = []
        follow = getattr(self, "_logs_follow", None)
        if follow is not None and follow.get_active():
            self._syncing_logs_follow = True
            try:
                follow.set_active(False)
            finally:
                self._syncing_logs_follow = False

    def _on_logs_follow_toggled(self, btn: Gtk.ToggleButton) -> None:
        if getattr(self, "_syncing_logs_follow", False):
            return
        if btn.get_active():
            self._start_logs_follow()
        else:
            self._stop_logs_follow_keep_toggle()

    def _stop_logs_follow_keep_toggle(self) -> None:
        self._logs_stream_gen += 1
        handle = self._logs_stream
        self._logs_stream = None
        if handle is not None:
            try:
                handle.stop()
            except Exception:  # noqa: BLE001
                pass

    def _start_logs_follow(self) -> None:
        client = self._client()
        nick = self._current_nickname()
        cid = self._selected_container_id()
        if client is None or not nick or not cid:
            self._stop_logs_follow()
            self._toast(_("Select a container to follow logs"))
            return
        self._stop_logs_follow_keep_toggle()
        self._resize_logs_ring()
        # ``logs -f --tail N`` already seeds recent history — do not call
        # ``_reload_logs`` here (it redirects back into Follow and recurses).
        gen = self._logs_stream_gen
        cmd = client.logs_follow_stream_command(
            cid, tail=int(self._tail_spin.get_value()),
            timestamps=self._ts_switch.get_active(),
        )
        stdin = None
        if client._password_mode:  # noqa: SLF001
            stdin = f"{client.sudo_password}\n"

        def on_line(line: str) -> None:
            if gen != self._logs_stream_gen:
                return
            self._logs_pending_lines.append(w.strip_ansi(line))
            if self._logs_flush_source is None:
                self._logs_flush_source = GLib.idle_add(self._flush_pending_log_lines)

        def on_done(_code: int) -> None:
            if gen != self._logs_stream_gen:
                return
            self._logs_stream = None
            if self._logs_follow.get_active():
                self._syncing_logs_follow = True
                try:
                    self._logs_follow.set_active(False)
                finally:
                    self._syncing_logs_follow = False

        self._logs_stream = self._start_command_stream(
            nick, cmd, on_line=on_line, on_done=on_done, input=stdin)

    def _flush_pending_log_lines(self) -> bool:
        self._logs_flush_source = None
        if not self._logs_pending_lines:
            return False
        batch = self._logs_pending_lines
        self._logs_pending_lines = []
        for line in batch:
            self._logs_lines.append(line)
        self._logs_raw = "\n".join(self._logs_lines)
        self._apply_log_filter()
        return False

    def _refresh_logs_targets(self) -> None:
        """Keep the dialog title in sync when the container list refreshes."""
        self._update_logs_target_label()

    def _selected_container_id(self) -> Optional[str]:
        return getattr(self, "_selected_cid", None)

    def _selected_container_name(self) -> str:
        return getattr(self, "_selected_name", None) or "container"

    def _reload_logs(self) -> None:
        """One-shot snapshot. If Follow is on, restart the stream instead."""
        client = self._client()
        cid = self._selected_container_id()
        if client is None or not cid:
            return
        if self._logs_follow.get_active():
            # Restart follow (stream includes --tail); never call this from
            # ``_start_logs_follow`` or the two recurse forever.
            self._start_logs_follow()
            return
        tail = int(self._tail_spin.get_value())
        ts = self._ts_switch.get_active()
        if not self._logs_raw and not self._logs_lines:
            self._logs_pulse.set_visible(True)
            self._pulse_start(self._logs_pulse)
        self._run_async(
            lambda: client.logs_snapshot(cid, tail=tail, timestamps=ts),
            self._on_logs,
        )

    def _on_logs(self, text: Optional[str], err: Optional[Exception]) -> None:
        self._pulse_stop(self._logs_pulse)
        self._logs_pulse.set_visible(False)
        if err is not None:
            self._logs_lines.clear()
            self._logs_raw = ""
            self._logs_buffer.set_text(_("Error: {err}").format(err=err), -1)
            return
        cleaned = w.strip_ansi(text or "")
        self._resize_logs_ring()
        self._logs_lines.clear()
        for line in cleaned.splitlines():
            self._logs_lines.append(line)
        self._logs_raw = "\n".join(self._logs_lines)
        self._apply_log_filter()

    _ERROR_MARKERS = ("error", "err ", "warn", "fail", "fatal", "exception",
                      "panic", "critical")

    def _on_logs_search_changed(self) -> None:
        self._logs_match_index = -1
        self._apply_log_filter()

    def _visible_log_lines(self) -> List[str]:
        lines = list(self._logs_lines)
        if self._logs_errors_only.get_active():
            lines = [ln for ln in lines
                     if any(m in ln.lower() for m in self._ERROR_MARKERS)]
        needle = self._logs_search.get_text()
        if needle and not self._logs_highlight_mode.get_active():
            if self._logs_case.get_active():
                lines = [ln for ln in lines if needle in ln]
            else:
                low = needle.lower()
                lines = [ln for ln in lines if low in ln.lower()]
        return lines

    def _apply_log_filter(self) -> None:
        lines = self._visible_log_lines()
        if not lines and not self._logs_lines:
            self._logs_buffer.set_text("(no output)", -1)
            self._logs_match_label.set_text("")
            return
        shown = "\n".join(lines) if lines else "(no matching lines)"
        self._logs_buffer.set_text(shown, -1)
        self._recompute_log_matches(shown)
        if self._logs_autoscroll.get_active() and self._logs_match_index < 0:
            GLib.idle_add(self._scroll_logs_to_end)

    def _recompute_log_matches(self, shown: str) -> None:
        needle = self._logs_search.get_text()
        self._logs_match_offsets = []
        start, end = self._logs_buffer.get_bounds()
        self._logs_buffer.remove_tag(self._logs_tag_match, start, end)
        self._logs_buffer.remove_tag(self._logs_tag_current, start, end)
        if not needle or shown.startswith("("):
            self._logs_match_label.set_text("")
            self._logs_match_index = -1
            return
        hay = shown if self._logs_case.get_active() else shown.lower()
        needle_cmp = needle if self._logs_case.get_active() else needle.lower()
        pos = 0
        while True:
            idx = hay.find(needle_cmp, pos)
            if idx < 0:
                break
            self._logs_match_offsets.append(idx)
            m_start = self._logs_buffer.get_iter_at_offset(idx)
            m_end = self._logs_buffer.get_iter_at_offset(idx + len(needle))
            self._logs_buffer.apply_tag(self._logs_tag_match, m_start, m_end)
            pos = idx + max(1, len(needle_cmp))
        n = len(self._logs_match_offsets)
        if n == 0:
            self._logs_match_label.set_text(_("No results"))
            self._logs_match_index = -1
            return
        if self._logs_match_index < 0 or self._logs_match_index >= n:
            self._logs_match_index = n - 1
        self._highlight_current_match()

    def _highlight_current_match(self) -> None:
        n = len(self._logs_match_offsets)
        if n == 0 or self._logs_match_index < 0:
            self._logs_match_label.set_text("")
            return
        needle = self._logs_search.get_text()
        start, end = self._logs_buffer.get_bounds()
        self._logs_buffer.remove_tag(self._logs_tag_current, start, end)
        off = self._logs_match_offsets[self._logs_match_index]
        m_start = self._logs_buffer.get_iter_at_offset(off)
        m_end = self._logs_buffer.get_iter_at_offset(off + len(needle))
        self._logs_buffer.apply_tag(self._logs_tag_current, m_start, m_end)
        self._logs_view.scroll_to_iter(m_start, 0.2, True, 0.0, 0.3)
        self._logs_match_label.set_text(f"{self._logs_match_index + 1}/{n}")

    def _logs_match_next(self) -> None:
        n = len(self._logs_match_offsets)
        if n == 0:
            return
        self._logs_match_index = (self._logs_match_index + 1) % n
        self._highlight_current_match()

    def _logs_match_prev(self) -> None:
        n = len(self._logs_match_offsets)
        if n == 0:
            return
        self._logs_match_index = (self._logs_match_index - 1) % n
        self._highlight_current_match()

    def _scroll_logs_to_end(self) -> bool:
        end = self._logs_buffer.get_end_iter()
        self._logs_view.scroll_to_iter(end, 0.0, False, 0.0, 0.0)
        return False

    def _logs_text(self) -> str:
        start, end = self._logs_buffer.get_bounds()
        return self._logs_buffer.get_text(start, end, False)

    def _clear_logs(self) -> None:
        self._logs_lines.clear()
        self._logs_raw = ""
        self._logs_buffer.set_text("", 0)
        self._logs_match_label.set_text("")

    def _copy_logs(self) -> None:
        text = self._logs_text()
        if not text:
            self._toast(_("Nothing to copy"))
            return
        display = self.get_display() or Gdk.Display.get_default()
        if display is not None:
            display.get_clipboard().set(text)
            self._toast(_("Logs copied"))

    def _save_logs(self) -> None:
        text = self._logs_text()
        if not text:
            self._toast(_("Nothing to save"))
            return
        dialog = Gtk.FileDialog.new()
        dialog.set_initial_name(f"{self._selected_container_name()}-logs.txt")

        def done(dlg: Gtk.FileDialog, result: Any) -> None:
            try:
                gfile = dlg.save_finish(result)
            except GLib.Error:
                return
            try:
                gfile.replace_contents(
                    text.encode("utf-8"), None, False,
                    Gio.FileCreateFlags.REPLACE_DESTINATION, None)
                self._toast(_("Logs saved"))
            except Exception as exc:  # noqa: BLE001
                self._toast(f"Save failed: {exc}")

        dialog.save(self._window(), None, done)

    def _follow_logs_selected(self) -> None:
        cid = self._selected_container_id()
        if cid:
            self._follow_logs(cid, self._selected_container_name())
