"""Tea Party TUI screen — podcast generation, history, and rehearse."""

from __future__ import annotations

import contextlib
import platform
import subprocess
import threading
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any

from textual.containers import Horizontal, Vertical
from textual.widgets import (
    Button,
    Checkbox,
    Collapsible,
    DataTable,
    Footer,
    Input,
    Label,
    Select,
    Static,
    TabbedContent,
    TabPane,
    TextArea,
)

from alice_cli.tui.screens.base import AliceScreen
from alice_cli.tui.widgets.breadcrumb import BreadcrumbBar
from alice_cli.tui.widgets.clock import TerminalClock
from alice_cli.tui.widgets.header_bar import AliceHeaderBar
from alice_cli.tui.widgets.output import OutputPanel

if TYPE_CHECKING:
    from textual.app import ComposeResult


# ── Neural style domain mapping ───────────────────────────────────────────────

_MODE_NEURAL_DOMAIN: dict[str, str] = {
    "conversation": "conversational",
    "interview": "conversational",
    "briefing": "news",
    "narrator": "n/a",
}


class TeaPartyScreen(AliceScreen):
    """TUI screen for Tea Party podcast generation and management.

    Inherits from AliceScreen, gaining: config property, _create_session(),
    _handle_error(), _wordstar property, and default Escape binding.

    Tabs:
    - Generate: Form with source, mode, model, voices, lang, pause-ms fields
      + Advanced Audio panel + Generate button
    - History: DataTable of previous sessions
    - Rehearse: Script JSON input (file path or paste) + Synthesize button
    """

    DEFAULT_CSS = """
    TeaPartyScreen .alice-panel {
        border: tall #FFB000;
        padding: 1 2;
        margin: 1 0;
        height: auto;
    }

    TeaPartyScreen #progress-panel {
        display: none;
    }

    TeaPartyScreen #script-preview-panel {
        display: none;
    }

    TeaPartyScreen #success-panel {
        display: none;
    }

    TeaPartyScreen #error-panel {
        display: none;
    }

    TeaPartyScreen #engine-warning {
        color: #FF6600;
        display: none;
    }

    TeaPartyScreen #neural-style-label {
        color: #FFD866;
    }
    """

    def __init__(self) -> None:
        super().__init__()
        self._running: bool = False
        self._pause_event = threading.Event()
        self._pause_event.set()  # Start unpaused
        self._last_error_context: dict[str, Any] | None = None

    def compose(self) -> ComposeResult:
        yield AliceHeaderBar()
        yield BreadcrumbBar(id="breadcrumb")
        if self._wordstar:
            from alice_cli.tui.widgets.wordstar_bar import WordStarBar

            yield WordStarBar(id="ws-bar-teaparty")

        with TabbedContent():
            # ── Generate Tab ──────────────────────────────────────────
            with TabPane("Generate", id="generate-tab"):  # noqa: SIM117
                with Vertical():
                    yield Label("Source (file path, URL, locker:ID, or text):")
                    yield Input(
                        placeholder="paper.txt, https://..., locker:abc123",
                        id="source-input",
                    )
                    yield Label("Mode:")
                    yield Select(
                        [
                            ("Conversation", "conversation"),
                            ("Narrator", "narrator"),
                            ("Briefing", "briefing"),
                            ("Interview", "interview"),
                        ],
                        value="conversation",
                        id="mode-select",
                    )
                    yield Label("Model:")
                    yield Input(
                        value="sonnet",
                        placeholder="e.g. sonnet, opus, haiku",
                        id="model-input",
                    )
                    yield Label("Voice A:")
                    yield Input(value="Matthew", id="voice-a-input")
                    yield Label("Voice B:")
                    yield Input(value="Ruth", id="voice-b-input")
                    yield Label("Language:")
                    yield Input(value="en-US", id="lang-input")
                    yield Label("Pause (ms):")
                    yield Input(value="500", id="pause-ms-input")

                    # Advanced Audio panel
                    with Collapsible(title="Advanced Audio", collapsed=True):
                        yield Select(
                            [
                                ("Auto", "auto"),
                                ("Generative", "generative"),
                                ("Long-Form (narrator)", "long-form"),
                                ("Neural (Styled)", "neural"),
                            ],
                            value="auto",
                            id="engine-select",
                        )
                        yield Static("", id="neural-style-label")
                        yield Static("", id="engine-warning")
                        yield Checkbox("SSML Micro-Pauses", value=True, id="ssml-breaks")

                    with Horizontal():
                        yield Button("Generate", id="btn-generate", variant="primary")
                        yield Button("Pause", id="btn-pause", variant="warning", disabled=True)
                        yield Button("Resume", id="btn-resume", variant="success", disabled=True)

                    # Progress panel
                    with Vertical(id="progress-panel", classes="alice-panel"):
                        yield Static("", id="progress-label")

                    # Script preview panel
                    with Vertical(id="script-preview-panel", classes="alice-panel"):
                        yield Label("Script Preview:")
                        yield OutputPanel(id="script-preview-output")

                    # Success panel
                    with Vertical(id="success-panel", classes="alice-panel"):
                        yield Static("", id="success-label")
                        with Horizontal():
                            yield Button("Play", id="btn-play")
                            yield Button("Publish", id="btn-publish")
                            yield Button("Copy Path", id="btn-copy-path")

                    # Error panel
                    with Vertical(id="error-panel", classes="alice-panel"):
                        yield OutputPanel(id="error-output")
                        yield Button("Retry", id="btn-retry", variant="error")

            # ── History Tab ───────────────────────────────────────────
            with TabPane("History", id="history-tab"):
                table: DataTable[str] = DataTable(id="history-table")
                table.add_columns("ID", "Timestamp", "Model", "Mode", "Duration")
                yield table
                yield Button("Refresh", id="btn-refresh-history", variant="primary")

            # ── Rehearse Tab ──────────────────────────────────────────
            with TabPane("Rehearse", id="rehearse-tab"):  # noqa: SIM117
                with Vertical():
                    yield Label("Script JSON file path:")
                    yield Input(
                        placeholder="/path/to/script.json",
                        id="rehearse-path-input",
                    )
                    yield Label("Or paste script JSON:")
                    yield TextArea(id="rehearse-text-input")
                    yield Button("Synthesize", id="btn-synthesize", variant="primary")

        yield TerminalClock()
        yield Footer()

    def on_mount(self) -> None:
        """Set model input default from persisted TUI state."""
        state = getattr(self.app, "tui_state", None)
        if state and state.last_model:
            self.query_one("#model-input", Input).value = state.last_model
        # Initialize neural-style-label for default mode
        self._update_neural_style_label()

    # ── Reactive behavior: Advanced Audio panel ───────────────────────────

    def on_select_changed(self, event: Select.Changed) -> None:
        """Handle mode and engine select changes for reactive behavior."""
        if event.select.id == "mode-select":
            self._update_neural_style_label()
            self._validate_engine_selection()
        elif event.select.id == "engine-select":
            self._validate_engine_selection()

    def _update_neural_style_label(self) -> None:
        """Update the neural-style-label based on current mode."""
        try:
            mode_select = self.query_one("#mode-select", Select)
            label = self.query_one("#neural-style-label", Static)
            mode_val = str(mode_select.value)
            domain = _MODE_NEURAL_DOMAIN.get(mode_val, "")
            label.update(f"Neural style: {domain}" if domain else "")
        except Exception:  # noqa: BLE001
            pass

    def _validate_engine_selection(self) -> None:
        """Check engine compatibility with current mode and show warnings."""
        try:
            engine_select = self.query_one("#engine-select", Select)
            mode_select = self.query_one("#mode-select", Select)
            warning_label = self.query_one("#engine-warning", Static)

            engine_val = str(engine_select.value)
            mode_val = str(mode_select.value)

            warning_msg = ""
            should_fallback = False

            if engine_val == "long-form" and mode_val != "narrator":
                warning_msg = (
                    "⚠ Long-Form engine is only compatible with narrator mode. "
                    "Falling back to Auto."
                )
                should_fallback = True

            if engine_val == "neural" and mode_val == "narrator":
                warning_msg = (
                    "⚠ Neural (Styled) is not applicable in narrator mode. " "Falling back to Auto."
                )
                should_fallback = True

            if warning_msg:
                warning_label.update(warning_msg)
                warning_label.display = True
                if should_fallback:
                    engine_select.value = "auto"
            else:
                warning_label.update("")
                warning_label.display = False
        except Exception:  # noqa: BLE001
            pass

    # ── Button handlers ───────────────────────────────────────────────────

    def on_button_pressed(self, event: Button.Pressed) -> None:  # noqa: C901
        """Route button presses to the appropriate handler."""
        btn_id = event.button.id or ""

        if btn_id == "btn-generate":
            self._start_generation()
        elif btn_id == "btn-pause":
            self._pause_pipeline()
        elif btn_id == "btn-resume":
            self._resume_pipeline()
        elif btn_id == "btn-retry":
            self._retry_pipeline()
        elif btn_id == "btn-synthesize":
            self._start_rehearse()
        elif btn_id == "btn-refresh-history":
            self._load_history()
        elif btn_id == "btn-play":
            self._play_audio()
        elif btn_id == "btn-publish":
            self._publish_audio()
        elif btn_id == "btn-copy-path":
            self._copy_output_path()

    # ── Generation pipeline ───────────────────────────────────────────────

    def _start_generation(self) -> None:
        """Validate inputs and launch the generation pipeline in a worker thread."""
        if self._running:
            return

        source = self.query_one("#source-input", Input).value.strip()
        if not source:
            self._show_error("Please enter a source (file path, URL, or locker:ID).")
            return

        model_id = self.query_one("#model-input", Input).value.strip()
        mode = str(self.query_one("#mode-select", Select).value)
        voice_a = self.query_one("#voice-a-input", Input).value.strip() or "Matthew"
        voice_b = self.query_one("#voice-b-input", Input).value.strip() or "Ruth"
        lang = self.query_one("#lang-input", Input).value.strip() or "en-US"
        pause_ms_str = self.query_one("#pause-ms-input", Input).value.strip()
        engine_val = str(self.query_one("#engine-select", Select).value)
        ssml_breaks = self.query_one("#ssml-breaks", Checkbox).value

        try:
            pause_ms = int(pause_ms_str) if pause_ms_str else 500
        except ValueError:
            self._show_error("Pause (ms) must be a number.")
            return

        # Persist model alias
        state = getattr(self.app, "tui_state", None)
        if state:
            state.last_model = model_id

        # Determine neural_style and engine override from Advanced Audio
        neural_style = engine_val == "neural"
        engine_override = engine_val if engine_val not in ("auto", "neural") else None

        self._last_error_context = {
            "source": source,
            "model_id": model_id,
            "mode": mode,
            "voice_a": voice_a,
            "voice_b": voice_b,
            "lang": lang,
            "pause_ms": pause_ms,
            "neural_style": neural_style,
            "engine_override": engine_override,
            "ssml_breaks": ssml_breaks,
        }

        self._running = True
        self._pause_event.set()
        self._hide_panels()
        self._show_progress("Starting generation pipeline…")
        self.query_one("#btn-pause", Button).disabled = False

        self.run_worker(
            lambda: self._run_pipeline(
                source=source,
                model_id=model_id,
                mode=mode,
                voice_a=voice_a,
                voice_b=voice_b,
                lang=lang,
                pause_ms=pause_ms,
                neural_style=neural_style,
                engine_override=engine_override,
                ssml_breaks=ssml_breaks,
            ),
            thread=True,
        )

    def _run_pipeline(  # noqa: C901, PLR0912, PLR0913
        self,
        *,
        source: str,
        model_id: str,
        mode: str,
        voice_a: str,
        voice_b: str,
        lang: str,
        pause_ms: int,
        neural_style: bool,
        engine_override: str | None,
        ssml_breaks: bool,
    ) -> None:
        """Worker thread: run the full Tea Party generation pipeline."""
        try:
            from alice_cli.aws.bedrock_context import create_bedrock_context
            from alice_cli.constants import get_memory_bucket
            from alice_cli.locker import CloudLocker
            from alice_cli.models import TokenUsage, resolve_model_id
            from alice_cli.session_record import build_session_record
            from alice_cli.teaparty.audio_locker import resolve_audio_bucket, upload_audio
            from alice_cli.teaparty.audio_stitcher import stitch_audio
            from alice_cli.teaparty.models import (
                GenerationMode,
                TeaPartyMetadata,
                serialize_script,
            )
            from alice_cli.teaparty.script_generator import generate_script
            from alice_cli.teaparty.source_loader import load_sources
            from alice_cli.teaparty.speech_synthesizer import select_engine, synthesize_turns
            from alice_cli.teaparty.voice_resolver import resolve_voices

            session = self._create_session()
            gen_mode = GenerationMode(mode)
            config = self.config

            # ── Stage 1: Load source material ─────────────────────────
            self._pause_event.wait()
            self.app.call_from_thread(self._update_progress, "Loading source material…")

            from alice_cli.auth import resolve_username

            username = resolve_username(config)
            locker = CloudLocker(
                config=config,
                username=username,
                bucket=get_memory_bucket(config.namespace, config.environment),
            )

            source_text = load_sources((source,), config, locker, session)

            # ── Stage 2: Generate script via Bedrock ──────────────────
            self._pause_event.wait()
            self.app.call_from_thread(self._update_progress, f"Generating script with {model_id}…")

            bedrock_ctx = create_bedrock_context(config)
            resolved_id = resolve_model_id(model_id, bedrock_ctx.inference_profile_arns)

            gen_result = generate_script(
                source_text=source_text,
                client=bedrock_ctx.client,
                model_id=resolved_id,
                mode=gen_mode,
                lang=lang,
            )

            script = gen_result.script
            tokens = gen_result.tokens

            # Update TokenTracker
            self.app.call_from_thread(self._update_token_tracker, tokens.input, tokens.output)

            # Show script preview
            preview_text = "\n".join(f"[{t.speaker}] {t.text}" for t in script.turns)
            self.app.call_from_thread(self._show_script_preview, preview_text)

            # ── Stage 3: Resolve voices ───────────────────────────────
            self._pause_event.wait()
            self.app.call_from_thread(self._update_progress, "Resolving voices…")

            resolved = resolve_voices(
                session,
                lang=lang,
                voice_a_override=voice_a if voice_a != "Matthew" else None,
                voice_b_override=voice_b if voice_b != "Ruth" else None,
            )

            final_voice_a = voice_a if voice_a != "Matthew" else resolved.voice_a
            final_voice_b = voice_b if voice_b != "Ruth" else resolved.voice_b

            # Determine engine
            if engine_override:
                engine = engine_override
            else:
                engine_a = select_engine(
                    gen_mode,
                    final_voice_a,
                    resolved.voice_a_engines,
                    neural_style,
                )
                engine = engine_a

            # ── Stage 4: Synthesize speech ────────────────────────────
            self._pause_event.wait()
            self.app.call_from_thread(
                self._update_progress,
                f"Synthesizing speech ({len(script.turns)} turns)…",
            )

            synth_segments = synthesize_turns(
                script=script,
                session=session,
                voice_a=final_voice_a,
                voice_b=final_voice_b,
                engine=engine,
                mode=gen_mode,
                neural_style=neural_style,
                quiet=True,
            )

            # ── Stage 5: Stitch audio ─────────────────────────────────
            self._pause_event.wait()
            self.app.call_from_thread(self._update_progress, "Stitching audio…")

            segments_bytes = [s.audio_data for s in synth_segments]
            speaker_ids = [
                t.speaker if gen_mode != GenerationMode.NARRATOR else "Host_A" for t in script.turns
            ]
            turn_texts = [t.text for t in script.turns]

            timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
            output_path = f"teaparty-{timestamp}.mp3"

            duration = stitch_audio(
                segments=segments_bytes,
                speaker_ids=speaker_ids,
                turn_texts=turn_texts,
                output_path=output_path,
                pause_ms=pause_ms,
            )

            # ── Stage 6: Upload and persist ───────────────────────────
            self._pause_event.wait()
            self.app.call_from_thread(self._update_progress, "Uploading to Audio Locker…")

            bucket = resolve_audio_bucket(config)
            artifact_key = f"{username}/teaparty/{timestamp}.mp3"

            try:
                audio_data = Path(output_path).read_bytes()
                upload_audio(session, bucket, artifact_key, audio_data)
            except Exception:  # noqa: BLE001
                pass  # best-effort upload

            meta = TeaPartyMetadata(
                artifact_key=artifact_key,
                audio_bucket=bucket,
                voice_a=final_voice_a,
                voice_b=final_voice_b,
                duration_seconds=duration,
                turn_count=len(script.turns),
                mode=mode,
                lang=lang,
            )

            record_tokens = tokens or TokenUsage(input=0, output=0, total=0)
            record = build_session_record(
                record_type="teaparty",
                model=model_id,
                prompt=source_text[:2000],
                response=serialize_script(script),
                tokens=record_tokens,
                metadata=meta.model_dump(),
            )

            with contextlib.suppress(Exception):
                locker.persist(record, "teaparty")

            # ── Done ──────────────────────────────────────────────────
            mins = int(duration) // 60
            secs = int(duration) % 60
            self.app.call_from_thread(
                self._show_success,
                f"Generated {mins}m {secs}s podcast "
                f"({len(script.turns)} turns) → {output_path}",
                output_path,
            )

        except Exception as exc:
            self.app.call_from_thread(self._handle_pipeline_error, exc)
        finally:
            self._running = False
            self.app.call_from_thread(self._disable_pause_buttons)

    # ── Rehearse pipeline ─────────────────────────────────────────────────

    def _start_rehearse(self) -> None:
        """Launch the rehearse (script-to-audio) pipeline."""
        if self._running:
            return

        path_val = self.query_one("#rehearse-path-input", Input).value.strip()
        text_val = self.query_one("#rehearse-text-input", TextArea).text.strip()

        if not path_val and not text_val:
            self._show_error("Please enter a script file path or paste script JSON.")
            return

        voice_a = self.query_one("#voice-a-input", Input).value.strip() or "Matthew"
        voice_b = self.query_one("#voice-b-input", Input).value.strip() or "Ruth"
        lang = self.query_one("#lang-input", Input).value.strip() or "en-US"
        pause_ms_str = self.query_one("#pause-ms-input", Input).value.strip()
        mode = str(self.query_one("#mode-select", Select).value)
        engine_val = str(self.query_one("#engine-select", Select).value)
        ssml_breaks = self.query_one("#ssml-breaks", Checkbox).value

        try:
            pause_ms = int(pause_ms_str) if pause_ms_str else 500
        except ValueError:
            self._show_error("Pause (ms) must be a number.")
            return

        neural_style = engine_val == "neural"
        engine_override = engine_val if engine_val not in ("auto", "neural") else None

        self._running = True
        self._pause_event.set()
        self._hide_panels()
        self._show_progress("Starting rehearse pipeline…")

        self.run_worker(
            lambda: self._run_rehearse(
                script_path=path_val or None,
                script_text=text_val or None,
                voice_a=voice_a,
                voice_b=voice_b,
                lang=lang,
                pause_ms=pause_ms,
                mode=mode,
                neural_style=neural_style,
                engine_override=engine_override,
                ssml_breaks=ssml_breaks,
            ),
            thread=True,
        )

    def _run_rehearse(  # noqa: PLR0913
        self,
        *,
        script_path: str | None,
        script_text: str | None,
        voice_a: str,
        voice_b: str,
        lang: str,
        pause_ms: int,
        mode: str,
        neural_style: bool,
        engine_override: str | None,
        ssml_breaks: bool,
    ) -> None:
        """Worker thread: run the rehearse (script-to-audio) pipeline."""
        try:
            from alice_cli.teaparty.audio_stitcher import stitch_audio
            from alice_cli.teaparty.models import GenerationMode, parse_script
            from alice_cli.teaparty.speech_synthesizer import select_engine, synthesize_turns
            from alice_cli.teaparty.voice_resolver import resolve_voices

            gen_mode = GenerationMode(mode)
            session = self._create_session()

            # Parse script
            script_json = Path(script_path).read_text("utf-8") if script_path else script_text or ""

            self.app.call_from_thread(self._update_progress, "Parsing script…")
            script = parse_script(script_json)

            # Show script preview
            preview_text = "\n".join(f"[{t.speaker}] {t.text}" for t in script.turns)
            self.app.call_from_thread(self._show_script_preview, preview_text)

            # Resolve voices
            self._pause_event.wait()
            self.app.call_from_thread(self._update_progress, "Resolving voices…")

            resolved = resolve_voices(
                session,
                lang=lang,
                voice_a_override=voice_a if voice_a != "Matthew" else None,
                voice_b_override=voice_b if voice_b != "Ruth" else None,
            )

            final_voice_a = voice_a if voice_a != "Matthew" else resolved.voice_a
            final_voice_b = voice_b if voice_b != "Ruth" else resolved.voice_b

            if engine_override:
                engine = engine_override
            else:
                engine_a = select_engine(
                    gen_mode,
                    final_voice_a,
                    resolved.voice_a_engines,
                    neural_style,
                )
                engine = engine_a

            # Synthesize
            self._pause_event.wait()
            self.app.call_from_thread(
                self._update_progress,
                f"Synthesizing speech ({len(script.turns)} turns)…",
            )

            synth_segments = synthesize_turns(
                script=script,
                session=session,
                voice_a=final_voice_a,
                voice_b=final_voice_b,
                engine=engine,
                mode=gen_mode,
                neural_style=neural_style,
                quiet=True,
            )

            # Stitch
            self._pause_event.wait()
            self.app.call_from_thread(self._update_progress, "Stitching audio…")

            segments_bytes = [s.audio_data for s in synth_segments]
            speaker_ids = [
                t.speaker if gen_mode != GenerationMode.NARRATOR else "Host_A" for t in script.turns
            ]
            turn_texts = [t.text for t in script.turns]

            timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
            output_path = f"teaparty-rehearse-{timestamp}.mp3"

            duration = stitch_audio(
                segments=segments_bytes,
                speaker_ids=speaker_ids,
                turn_texts=turn_texts,
                output_path=output_path,
                pause_ms=pause_ms,
            )

            mins = int(duration) // 60
            secs = int(duration) % 60
            self.app.call_from_thread(
                self._show_success,
                f"Rehearsed {mins}m {secs}s podcast "
                f"({len(script.turns)} turns) → {output_path}",
                output_path,
            )

        except Exception as exc:
            self.app.call_from_thread(self._handle_pipeline_error, exc)
        finally:
            self._running = False
            self.app.call_from_thread(self._disable_pause_buttons)

    # ── History tab ───────────────────────────────────────────────────────

    def _load_history(self) -> None:
        """Load Tea Party sessions into the history DataTable."""
        self.run_worker(self._fetch_history, thread=True)

    def _fetch_history(self) -> None:
        """Worker thread: fetch history from Cloud Locker."""
        try:
            from alice_cli.auth import resolve_username
            from alice_cli.constants import get_memory_bucket
            from alice_cli.locker import CloudLocker

            config = self.config
            username = resolve_username(config)
            locker = CloudLocker(
                config=config,
                username=username,
                bucket=get_memory_bucket(config.namespace, config.environment),
            )

            index = locker.load_index()
            entries = [e for e in index.entries if e.type == "teaparty"]
            entries.sort(key=lambda e: e.timestamp, reverse=True)

            self.app.call_from_thread(self._populate_history, entries)
        except Exception as exc:
            self.app.call_from_thread(self._handle_pipeline_error, exc)

    def _populate_history(self, entries: list[Any]) -> None:
        """Populate the history DataTable with session entries."""
        table = self.query_one("#history-table", DataTable)
        table.clear()
        for entry in entries[:20]:
            meta = entry.metadata or {} if hasattr(entry, "metadata") else {}
            table.add_row(
                entry.id[:8],
                entry.timestamp[:19],
                entry.model,
                meta.get("mode", "—"),
                meta.get("duration_seconds", "—"),
            )

    # ── Pause / Resume ────────────────────────────────────────────────────

    def _pause_pipeline(self) -> None:
        """Pause the pipeline between stages."""
        self._pause_event.clear()
        self.query_one("#btn-pause", Button).disabled = True
        self.query_one("#btn-resume", Button).disabled = False
        self._update_progress("Pipeline paused — press Resume to continue.")

    def _resume_pipeline(self) -> None:
        """Resume the paused pipeline."""
        self._pause_event.set()
        self.query_one("#btn-pause", Button).disabled = False
        self.query_one("#btn-resume", Button).disabled = True

    # ── Play / Publish / Copy Path ────────────────────────────────────────

    def _play_audio(self) -> None:
        """Open the generated MP3 with the system audio player."""
        success_label = self.query_one("#success-label", Static)
        text = str(success_label.renderable)
        # Extract path from success message (last word after →)
        path = text.split("→")[-1].strip() if "→" in text else ""
        if not path or not Path(path).exists():
            self.notify("Audio file not found.", severity="error")
            return

        opener = "open" if platform.system() == "Darwin" else "xdg-open"
        try:
            subprocess.Popen([opener, path])  # noqa: S603
            self.notify(f"Playing {path}", severity="information")
        except FileNotFoundError:
            self.notify(f"Cannot open audio player ({opener}).", severity="error")

    def _publish_audio(self) -> None:
        """Placeholder for publish action."""
        self.notify("Publish not yet implemented in TUI.", severity="information")

    def _copy_output_path(self) -> None:
        """Copy the output file path to clipboard via notification."""
        success_label = self.query_one("#success-label", Static)
        text = str(success_label.renderable)
        path = text.split("→")[-1].strip() if "→" in text else ""
        if path:
            self.notify(f"Path: {path}", severity="information")

    # ── Retry ─────────────────────────────────────────────────────────────

    def _retry_pipeline(self) -> None:
        """Retry the last failed pipeline run."""
        if self._last_error_context:
            self._start_generation()

    # ── Thread-safe UI helpers ────────────────────────────────────────────

    def _show_progress(self, message: str) -> None:
        """Show the progress panel with a message."""
        panel = self.query_one("#progress-panel", Vertical)
        panel.display = True
        self.query_one("#progress-label", Static).update(message)

    def _update_progress(self, message: str) -> None:
        """Update the progress panel message (called from worker via call_from_thread)."""
        self._show_progress(message)

    def _show_script_preview(self, text: str) -> None:
        """Show the script preview panel."""
        panel = self.query_one("#script-preview-panel", Vertical)
        panel.display = True
        output = self.query_one("#script-preview-output", OutputPanel)
        output.clear()
        output.write(text)

    def _show_success(self, message: str, output_path: str) -> None:
        """Show the success panel with result info."""
        self.query_one("#progress-panel", Vertical).display = False
        panel = self.query_one("#success-panel", Vertical)
        panel.display = True
        self.query_one("#success-label", Static).update(message)

    def _show_error(self, message: str) -> None:
        """Show an error message in the error panel."""
        panel = self.query_one("#error-panel", Vertical)
        panel.display = True
        output = self.query_one("#error-output", OutputPanel)
        output.clear()
        output.write(f"[#FF6600]{message}[/]")

    def _handle_pipeline_error(self, exc: Exception) -> None:
        """Handle a pipeline error using inherited _handle_error."""
        self.query_one("#progress-panel", Vertical).display = False
        panel = self.query_one("#error-panel", Vertical)
        panel.display = True
        output = self.query_one("#error-output", OutputPanel)
        output.clear()
        self._handle_error(exc, output)

    def _hide_panels(self) -> None:
        """Hide all result panels."""
        for panel_id in (
            "#progress-panel",
            "#script-preview-panel",
            "#success-panel",
            "#error-panel",
        ):
            with contextlib.suppress(Exception):
                self.query_one(panel_id, Vertical).display = False

    def _disable_pause_buttons(self) -> None:
        """Disable pause/resume buttons when pipeline is not running."""
        self.query_one("#btn-pause", Button).disabled = True
        self.query_one("#btn-resume", Button).disabled = True
