"""Interactive search TUI built with prompt_toolkit."""

from __future__ import annotations

import asyncio
import logging
import re
from collections import Counter, deque
from dataclasses import dataclass
from typing import TYPE_CHECKING

from prompt_toolkit import Application
from prompt_toolkit.buffer import Buffer
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.filters import Condition
from prompt_toolkit.layout import HSplit, Layout, Window
from prompt_toolkit.layout.containers import ConditionalContainer
from prompt_toolkit.layout.controls import FormattedTextControl
from prompt_toolkit.layout.dimension import Dimension
from prompt_toolkit.widgets import TextArea

from xc_dl.api.models import APIResponse, Recording
from xc_dl.api.query import SEARCH_TAGS
from xc_dl.tui.cache import SearchCache
from xc_dl.tui.taxonomy import TaxonomyIndex
from xc_dl.tui.worldmap import render_map
from xc_dl.utils import parse_duration

if TYPE_CHECKING:
    from xc_dl.config import Config

SPINNER_CHARS = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
LOG_PANEL_CAPACITY = 8

_TAG_RE = re.compile(r"^([a-z]+):")


def _expand_bare_query(raw_query: str) -> str:
    """If the query has no tag prefixes, wrap it as en:<query>."""
    tokens = raw_query.split()
    if not tokens:
        return raw_query

    has_tag = any(
        (m := _TAG_RE.match(tok)) and m.group(1) in SEARCH_TAGS
        for tok in tokens
    )
    if has_tag:
        return raw_query

    phrase = " ".join(tokens)
    if " " in phrase:
        return f'en:"{phrase}"'
    return f"en:{phrase}"


class _TuiLogHandler(logging.Handler):
    """Logging handler that captures log output into the TUI log panel."""

    def __init__(self, log_lines: deque[str], invalidate: object) -> None:
        super().__init__(level=logging.WARNING)
        self._log_lines = log_lines
        self._invalidate = invalidate

    def emit(self, record: logging.LogRecord) -> None:
        try:
            msg = f"[{record.levelname}] {record.getMessage()}"
            self._log_lines.append(msg)
            self._invalidate()  # type: ignore[operator]
        except Exception:
            self.handleError(record)


@dataclass
class SearchResult:
    """Result returned when exiting the TUI."""
    action: str  # "download", "save", "cancel"
    query: str
    selected_ids: list[str]
    preset_name: str = ""
    preset_description: str = ""


class InteractiveSearchApp:
    """Full-screen interactive search TUI."""

    def __init__(
        self,
        api_key: str,
        cfg: Config,
        initial_query: str = "",
    ) -> None:
        self._api_key = api_key
        self._cfg = cfg
        self._initial_query = initial_query

        # State
        self._recordings: list[Recording] = []
        self._response: APIResponse | None = None
        self._current_page = 1
        self._cursor = 0
        self._selected: set[str] = set()  # XC IDs
        self._searching = False
        self._spinner_frame = 0
        self._debounce_task: asyncio.Task | None = None  # type: ignore[type-arg]
        self._spinner_task: asyncio.Task | None = None  # type: ignore[type-arg]
        self._last_query = ""
        self._show_help = False
        self._result: SearchResult | None = None
        self._save_mode = False
        self._save_step = 0  # 0 = name, 1 = description
        self._error_message = ""
        self._show_map = False

        # Log capture — ring buffer shown in TUI panel
        self._log_lines: deque[str] = deque(maxlen=LOG_PANEL_CAPACITY)

        # Components
        self._cache = SearchCache()
        self._taxonomy = TaxonomyIndex()
        self._taxonomy.load()

        # Build UI
        self._query_area = TextArea(
            text=initial_query,
            multiline=False,
            prompt="Query: ",
            height=1,
        )
        self._query_area.buffer.on_text_changed += self._on_text_changed

        # Save preset prompt inputs (shown inline when Ctrl+S pressed)
        self._save_name_area = TextArea(
            multiline=False,
            prompt="Preset name: ",
            height=1,
        )
        self._save_desc_area = TextArea(
            multiline=False,
            prompt="Description (optional): ",
            height=1,
        )

        self._app: Application[SearchResult | None] | None = None

    def _build_layout(self) -> Layout:
        save_filter = Condition(lambda: self._save_mode)
        save_name_filter = Condition(lambda: self._save_mode and self._save_step == 0)
        save_desc_filter = Condition(lambda: self._save_mode and self._save_step == 1)
        map_filter = Condition(lambda: self._show_map)

        return Layout(
            HSplit([
                # Header
                Window(
                    FormattedTextControl(self._render_header),
                    height=1,
                    style="reverse",
                ),
                # Query input
                self._query_area,
                # Separator
                Window(height=1, char="─"),
                # Stats panel
                Window(
                    FormattedTextControl(self._render_stats),
                    height=4,
                ),
                # Distribution map (toggle with Ctrl+M)
                ConditionalContainer(
                    HSplit([
                        Window(height=1, char="─"),
                        Window(
                            FormattedTextControl(self._render_map),
                            height=20,
                        ),
                    ]),
                    filter=map_filter,
                ),
                # Separator
                Window(height=1, char="─"),
                # Table
                Window(
                    FormattedTextControl(self._render_table),
                    height=Dimension(weight=1),
                ),
                # Separator
                Window(height=1, char="─"),
                # Log panel
                Window(
                    FormattedTextControl(self._render_logs),
                    height=Dimension(max=LOG_PANEL_CAPACITY + 1, preferred=0),
                ),
                # Save prompt (shown when Ctrl+S pressed)
                ConditionalContainer(
                    Window(
                        FormattedTextControl(
                            lambda: [("", " Save query preset (Esc to cancel)")]
                        ),
                        height=1,
                        style="reverse",
                    ),
                    filter=save_filter,
                ),
                ConditionalContainer(self._save_name_area, filter=save_name_filter),
                ConditionalContainer(self._save_desc_area, filter=save_desc_filter),
                # Footer
                Window(
                    FormattedTextControl(self._render_footer),
                    height=2,
                ),
            ]),
            # Focus the save name input when in save mode step 0
        )

    def _build_keybindings(self) -> KeyBindings:
        kb = KeyBindings()

        @kb.add("up")
        def _up(event: object) -> None:
            if self._recordings and self._cursor > 0:
                self._cursor -= 1

        @kb.add("down")
        def _down(event: object) -> None:
            if self._recordings and self._cursor < len(self._recordings) - 1:
                self._cursor += 1

        @kb.add("c-space")
        def _select(event: object) -> None:
            if not self._recordings:
                return
            rec = self._recordings[self._cursor]
            xc_id = rec.id
            if xc_id in self._selected:
                self._selected.discard(xc_id)
            else:
                self._selected.add(xc_id)

        @kb.add("enter")
        def _search_now(event: object) -> None:
            if self._debounce_task:
                self._debounce_task.cancel()
            query = self._query_area.text.strip()
            if query:
                asyncio.get_event_loop().create_task(self._do_search(query, page=1))

        @kb.add("tab")
        def _next_page(event: object) -> None:
            if self._response and self._current_page < self._response.numPages:
                query = self._query_area.text.strip()
                if query:
                    asyncio.get_event_loop().create_task(
                        self._do_search(query, page=self._current_page + 1)
                    )

        @kb.add("s-tab")
        def _prev_page(event: object) -> None:
            if self._current_page > 1:
                query = self._query_area.text.strip()
                if query:
                    asyncio.get_event_loop().create_task(
                        self._do_search(query, page=self._current_page - 1)
                    )

        @kb.add("c-a")
        def _select_all(event: object) -> None:
            if not self._recordings:
                return
            page_ids = {r.id for r in self._recordings}
            if page_ids.issubset(self._selected):
                self._selected -= page_ids
            else:
                self._selected |= page_ids

        @kb.add("c-d")
        def _download(event: object) -> None:
            query = _expand_bare_query(self._query_area.text.strip())
            if query:
                self._result = SearchResult(
                    action="download",
                    query=query,
                    selected_ids=list(self._selected),
                )
                assert self._app is not None
                self._app.exit(result=self._result)

        @kb.add("c-s")
        def _save(event: object) -> None:
            query = self._query_area.text.strip()
            if not query:
                return
            self._save_mode = True
            self._save_step = 0
            self._save_name_area.text = ""
            self._save_desc_area.text = ""
            assert self._app is not None
            self._app.layout.focus(self._save_name_area)

        @kb.add("enter", filter=Condition(lambda: self._save_mode and self._save_step == 0))
        def _save_name_submit(event: object) -> None:
            name = self._save_name_area.text.strip()
            if not name:
                self._log("Preset name cannot be empty")
                return
            self._save_step = 1
            assert self._app is not None
            self._app.layout.focus(self._save_desc_area)

        @kb.add("enter", filter=Condition(lambda: self._save_mode and self._save_step == 1))
        def _save_desc_submit(event: object) -> None:
            query = _expand_bare_query(self._query_area.text.strip())
            name = self._save_name_area.text.strip()
            desc = self._save_desc_area.text.strip()
            self._result = SearchResult(
                action="save",
                query=query,
                selected_ids=list(self._selected),
                preset_name=name,
                preset_description=desc,
            )
            assert self._app is not None
            self._app.exit(result=self._result)

        @kb.add("escape", filter=Condition(lambda: self._save_mode))
        def _save_cancel(event: object) -> None:
            self._save_mode = False
            self._save_step = 0
            assert self._app is not None
            self._app.layout.focus(self._query_area)

        @kb.add("escape", filter=Condition(lambda: not self._save_mode))
        def _quit(event: object) -> None:
            assert self._app is not None
            self._app.exit(result=None)

        @kb.add("f1")
        def _toggle_help(event: object) -> None:
            self._show_help = not self._show_help

        @kb.add("c-g")
        def _toggle_map(event: object) -> None:
            self._show_map = not self._show_map

        return kb

    # ── Rendering ─────────────────────────────────────────────────────

    def _render_header(self) -> list[tuple[str, str]]:
        return [("", " xc-dl interactive search ")]

    def _render_stats(self) -> list[tuple[str, str]]:
        if self._searching:
            c = SPINNER_CHARS[self._spinner_frame % len(SPINNER_CHARS)]
            return [("", f" {c} Fetching results...")]

        if self._response is None:
            return [("", " Enter a query to search. Use tags: gen:, sp:, cnt:, grp:, q:, type:, ...")]

        resp = self._response
        recs = self._recordings

        # Line 1: counts
        lines: list[str] = []
        lines.append(
            f" {resp.total_recordings:,} recordings  \u00b7  "
            f"{resp.total_species:,} species  \u00b7  "
            f"{resp.numPages} pages"
        )

        # Line 2: quality distribution
        quality_counts: Counter[str] = Counter()
        type_counts: Counter[str] = Counter()
        total_duration = 0.0
        for rec in recs:
            if rec.q:
                quality_counts[rec.q] += 1
            for t in rec.type_list:
                type_counts[t] += 1
            total_duration += parse_duration(rec.length)

        if quality_counts:
            q_str = "  ".join(f"{k}:{v}" for k, v in sorted(quality_counts.items()))
            lines.append(f" Quality: {q_str}")
        else:
            lines.append(" Quality: -")

        # Line 3: types
        if type_counts:
            t_str = ", ".join(f"{k} ({v})" for k, v in type_counts.most_common(5))
            lines.append(f" Types: {t_str}")
        else:
            lines.append(" Types: -")

        # Line 4: duration estimate
        hours = total_duration / 3600
        est_gb = resp.total_recordings * 5 / 1024
        lines.append(f" Duration: ~{hours:.1f} h   Est. disk: ~{est_gb:.1f} GB")

        return [("", "\n".join(lines))]

    def _render_map(self) -> list[tuple[str, str]]:
        return render_map(self._recordings)

    def _render_table(self) -> list[tuple[str, str]]:
        if self._show_help:
            return [("", self._help_text())]

        if not self._recordings:
            return [("", "")]

        header = (
            f" {'XC ID':>10}  {'Species':<20} {'En Name':<18} "
            f"{'Q':>2} {'Type':<10} {'Len':>5} {'Cnt':>3}"
        )
        sep = " " + "\u2500" * 10 + "\u2500\u2500" + "\u2500" * 20 + "\u2500" + "\u2500" * 18 + "\u2500" + "\u2500" * 3 + "\u2500" + "\u2500" * 10 + "\u2500" + "\u2500" * 5 + "\u2500" + "\u2500" * 3

        lines = [header, sep]

        for i, rec in enumerate(self._recordings):
            prefix = "\u25b6" if i == self._cursor else (" " if rec.id not in self._selected else "\u25cf")
            xc_id = rec.id.zfill(8)
            species = rec.species_name[:20]
            en = (rec.en or "")[:18]
            q = rec.q or "-"
            rec_type = (rec.type_list[0] if rec.type_list else "")[:10]
            length = rec.length or ""
            cnt = (rec.cnt or "")[:3]

            line = (
                f"{prefix} {xc_id:>10}  {species:<20} {en:<18} "
                f"{q:>2} {rec_type:<10} {length:>5} {cnt:>3}"
            )

            if i == self._cursor:
                lines.append(line)
            elif rec.id in self._selected:
                lines.append(line)
            else:
                lines.append(line)

        return [("", "\n".join(lines))]

    def _render_logs(self) -> list[tuple[str, str]]:
        if not self._log_lines:
            return [("", "")]
        return [("", "\n".join(self._log_lines))]

    def _render_footer(self) -> list[tuple[str, str]]:
        if self._save_mode:
            if self._save_step == 0:
                return [("", " Enter a name for this preset, then press Enter\n Esc:Cancel")]
            else:
                return [("", " Enter an optional description, then press Enter to save\n Esc:Cancel")]

        page_info = ""
        if self._response:
            page_info = f"  Page {self._current_page}/{self._response.numPages}"

        sel_count = len(self._selected)
        line1 = f" \u2191\u2193:Navigate  ^Space:Select  Enter:Search  Tab/S-Tab:Page{page_info}"
        line2 = f" ^D:Download  ^S:Save preset  ^A:Select all  ^G:Map  F1:Help  Esc:Quit [{sel_count} selected]"
        return [("", f"{line1}\n{line2}")]

    def _help_text(self) -> str:
        return (
            " Help (press F1 to close)\n"
            " ────────────────────────\n"
            " Type a query using xeno-canto search tags:\n"
            "   gen:Tyto         - genus\n"
            "   sp:\"Tyto alba\"   - species\n"
            "   cnt:\"South Africa\" - country\n"
            "   grp:birds        - organism group\n"
            "   q:\">C\"           - quality\n"
            "   type:song        - recording type\n"
            "   fam:Strigidae    - family\n"
            "   area:africa      - geographic area\n"
            "   year:2020        - year\n"
            "   rec:\"John Smith\" - recordist\n"
            "\n"
            " Multiple tags can be combined:\n"
            "   grp:birds cnt:\"South Africa\" q:\">C\"\n"
        )

    # ── Search logic ──────────────────────────────────────────────────

    def _on_text_changed(self, buff: Buffer) -> None:
        if self._debounce_task:
            self._debounce_task.cancel()
        query = buff.text.strip()
        if query:
            self._debounce_task = asyncio.get_event_loop().create_task(
                self._debounced_search(query)
            )

    async def _debounced_search(self, query: str) -> None:
        await asyncio.sleep(0.6)
        await self._do_search(query, page=1)

    def _log(self, message: str) -> None:
        """Append a message to the in-TUI log panel."""
        self._log_lines.append(message)
        self._invalidate()

    async def _do_search(self, query: str, page: int = 1) -> None:
        # Expand bare terms (no tag prefix) into en:<query>
        api_query = _expand_bare_query(query)

        # Check cache first
        cached = self._cache.get(api_query, page)
        if cached is not None:
            self._update_results(cached, page, api_query)
            return

        self._searching = True
        self._invalidate()
        try:
            import aiohttp
            from xc_dl.api.client import XenoCantoClient

            async with aiohttp.ClientSession() as session:
                client = XenoCantoClient(
                    api_key=self._api_key,
                    session=session,
                    metadata_concurrency=self._cfg.metadata_concurrency,
                    rate_limit=self._cfg.rate_limit,
                )
                response = await client.fetch_page(api_query, page=page, per_page=100)

            self._cache.put(api_query, page, response)

            # Organic taxonomy growth
            new_taxa = self._taxonomy.ingest(list(response.recordings))
            if new_taxa > 0:
                self._taxonomy.save_cache()

            if api_query != query:
                self._log(f"Searching: {api_query}")

            self._update_results(response, page, api_query)
        except Exception as exc:
            self._searching = False
            self._log(f"Search error: {exc}")
            self._invalidate()

    def _update_results(self, response: APIResponse, page: int, api_query: str = "") -> None:
        self._response = response
        self._recordings = list(response.recordings)
        self._current_page = page
        self._cursor = 0
        self._searching = False
        self._last_query = api_query or self._query_area.text.strip()
        self._invalidate()

    def _invalidate(self) -> None:
        if self._app:
            self._app.invalidate()

    # ── Spinner ───────────────────────────────────────────────────────

    async def _run_spinner(self) -> None:
        while True:
            await asyncio.sleep(0.08)
            self._spinner_frame += 1
            if self._searching:
                self._invalidate()

    # ── Entry point ───────────────────────────────────────────────────

    async def run(self) -> SearchResult | None:
        """Run the TUI and return the result."""
        # First-launch taxonomy seed
        if not self._taxonomy.has_user_data():
            await self._maybe_seed_taxonomy()

        layout = self._build_layout()
        kb = self._build_keybindings()

        self._app = Application(
            layout=layout,
            key_bindings=kb,
            full_screen=True,
            mouse_support=False,
        )

        # Redirect all log output into the TUI panel instead of stderr
        tui_handler = _TuiLogHandler(self._log_lines, self._invalidate)
        root = logging.getLogger()
        saved_handlers = root.handlers[:]
        root.handlers = [tui_handler]

        self._spinner_task = asyncio.ensure_future(self._run_spinner())

        # Auto-search if initial query provided
        if self._initial_query.strip():
            asyncio.get_event_loop().call_soon(
                lambda: asyncio.ensure_future(
                    self._do_search(self._initial_query.strip(), page=1)
                )
            )

        try:
            result = await self._app.run_async()
            return result
        finally:
            # Restore original log handlers
            root.handlers = saved_handlers

            if self._spinner_task:
                self._spinner_task.cancel()
                try:
                    await self._spinner_task
                except asyncio.CancelledError:
                    pass

    async def _maybe_seed_taxonomy(self) -> None:
        """Prompt user to seed taxonomy on first launch."""
        import sys

        sys.stdout.write(
            "No local taxonomy index found. Fetch species data from Xeno-canto?\n"
            "This enables auto-complete for species, genus, and family names.\n"
            "[Y/n]: "
        )
        sys.stdout.flush()

        try:
            answer = input().strip().lower()
        except (EOFError, KeyboardInterrupt):
            return

        if answer in ("", "y", "yes"):
            sys.stdout.write("Seeding taxonomy index...")
            sys.stdout.flush()

            import aiohttp
            from xc_dl.api.client import XenoCantoClient

            async with aiohttp.ClientSession() as session:
                client = XenoCantoClient(
                    api_key=self._api_key,
                    session=session,
                    metadata_concurrency=self._cfg.metadata_concurrency,
                    rate_limit=self._cfg.rate_limit,
                )
                new_count = await self._taxonomy.seed(client)

            sys.stdout.write(f" done ({new_count} entries)\n")
            sys.stdout.flush()
