"""Modern Textual UI for driving Medeia-Macina pipelines."""

from __future__ import annotations

import json
import re
import sys
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple

from textual import on, work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.events import Key
from textual.containers import Container, Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import (
    Button,
    DataTable,
    Footer,
    Header,
    Input,
    Label,
    OptionList,
    Select,
    Static,
    TextArea,
)
from textual.widgets.option_list import Option


BASE_DIR = Path(__file__).resolve().parent
REPO_ROOT = BASE_DIR
TUI_DIR = REPO_ROOT / "TUI"
for path in (REPO_ROOT, TUI_DIR):
    str_path = str(path)
    if str_path not in sys.path:
        sys.path.insert(0, str_path)

from TUI.pipeline_runner import PipelineRunResult  # type: ignore  # noqa: E402
from SYS.result_table import ResultTable, extract_hash_value, extract_store_value  # type: ignore  # noqa: E402

from SYS.config import load_config  # type: ignore  # noqa: E402
from Store.registry import Store as StoreRegistry  # type: ignore  # noqa: E402
from SYS.cmdlet_catalog import ensure_registry_loaded, list_cmdlet_names  # type: ignore  # noqa: E402
from SYS.cli_syntax import validate_pipeline_text  # type: ignore  # noqa: E402

from TUI.pipeline_runner import PipelineRunner  # type: ignore  # noqa: E402


def _dedup_preserve_order(items: List[str]) -> List[str]:
    out: List[str] = []
    seen: set[str] = set()
    for raw in items:
        s = str(raw or "").strip()
        if not s:
            continue
        key = s.lower()
        if key in seen:
            continue
        seen.add(key)
        out.append(s)
    return out


def _extract_tag_names(emitted: Sequence[Any]) -> List[str]:
    tags: List[str] = []
    for obj in emitted or []:
        try:
            if hasattr(obj, "tag_name"):
                val = getattr(obj, "tag_name")
                if val and isinstance(val, str):
                    tags.append(val)
                    continue
        except Exception:
            pass

        if isinstance(obj, dict):
            # Prefer explicit tag lists
            tag_list = obj.get("tag")
            if isinstance(tag_list, (list, tuple)):
                for t in tag_list:
                    if isinstance(t, str) and t.strip():
                        tags.append(t.strip())
                if tag_list:
                    continue
            # Fall back to individual tag_name/value/name strings
            for k in ("tag_name", "value", "name"):
                v = obj.get(k)
                if isinstance(v, str) and v.strip():
                    tags.append(v.strip())
                    break
            continue
    return _dedup_preserve_order(tags)


def _extract_tag_names_from_table(table: Any) -> List[str]:
    if not table:
        return []
    sources: List[Any] = []
    get_payloads = getattr(table, "get_payloads", None)
    if callable(get_payloads):
        try:
            payloads = get_payloads()
            if payloads:
                sources.extend(payloads)
        except Exception:
            pass
    rows = getattr(table, "rows", []) or []
    for row in rows:
        for col in getattr(row, "columns", []) or []:
            if str(getattr(col, "name", "") or "").strip().lower() == "tag":
                val = getattr(col, "value", None)
                if val:
                    sources.append({"tag_name": val})
    if not sources:
        return []
    return _extract_tag_names(sources)


class TextPopup(ModalScreen[None]):

    def __init__(self, *, title: str, text: str) -> None:
        super().__init__()
        self._title = str(title)
        self._text = str(text or "")

    def compose(self) -> ComposeResult:
        yield Static(self._title, id="popup-title")
        yield TextArea(self._text, id="popup-text", read_only=True)
        yield Button("Close", id="popup-close")

    def on_button_pressed(self, event: Button.Pressed) -> None:
        if event.button.id == "popup-close":
            self.dismiss(None)


class TagEditorPopup(ModalScreen[None]):

    def __init__(
        self,
        *,
        seeds: Any,
        store_name: str,
        file_hash: Optional[str]
    ) -> None:
        super().__init__()
        self._seeds = seeds
        self._store = str(store_name or "").strip()
        self._hash = str(file_hash or "").strip() if file_hash else ""
        self._original_tags: List[str] = []
        self._status: Optional[Static] = None
        self._editor: Optional[TextArea] = None

    def compose(self) -> ComposeResult:
        yield Static("Tags", id="popup-title")
        yield TextArea("", id="tags-editor")
        with Horizontal(id="tags-buttons"):
            yield Button("Save", id="tags-save")
            yield Button("Close", id="tags-close")
            yield Static("", id="tags-status")

    def on_mount(self) -> None:
        self._status = self.query_one("#tags-status", Static)
        self._editor = self.query_one("#tags-editor", TextArea)
        self._set_status("Loading tags…")
        self._load_tags_background()

    def _set_status(self, msg: str) -> None:
        if self._status:
            self._status.update(str(msg or ""))

    @work(thread=True)
    def _load_tags_background(self) -> None:
        app = self.app  # PipelineHubApp
        tags = self._fetch_tags_from_store()
        if not tags:
            try:
                runner: PipelineRunner = getattr(app, "executor")
                cmd = "@1 | get-tag"
                res = runner.run_pipeline(cmd, seeds=self._seeds, isolate=True)
                tags = _extract_tag_names_from_table(getattr(res, "result_table", None))
                if not tags:
                    tags = _extract_tag_names(getattr(res, "emitted", []))
            except Exception as exc:
                tags = []
                try:
                    app.call_from_thread(
                        self._set_status,
                        f"Error: {type(exc).__name__}: {exc}"
                    )
                except Exception:
                    self._set_status(f"Error: {type(exc).__name__}: {exc}")
        self._original_tags = tags
        try:
            app.call_from_thread(self._apply_loaded_tags, tags)
        except Exception:
            self._apply_loaded_tags(tags)

    def _apply_loaded_tags(self, tags: List[str]) -> None:
        if self._editor:
            self._editor.text = "\n".join(tags)
        self._set_status(f"Loaded {len(tags)} tag(s)")

    def _fetch_tags_from_store(self) -> Optional[List[str]]:
        if not self._store or not self._hash:
            return None
        try:
            cfg = load_config() or {}
        except Exception:
            cfg = {}
        store_key = str(self._store or "").strip()
        hash_value = str(self._hash or "").strip().lower()
        if not store_key or not hash_value:
            return None
        try:
            registry = StoreRegistry(config=cfg, suppress_debug=True)
        except Exception:
            return []
        match = None
        normalized = store_key.lower()
        for name in registry.list_backends():
            if str(name or "").strip().lower() == normalized:
                match = name
                break
        if match is None:
            return None
        try:
            backend = registry[match]
        except KeyError:
            return None
        try:
            tags, _src = backend.get_tag(hash_value, config=cfg)
            if not tags:
                return []
            filtered = [str(t).strip() for t in tags if str(t).strip()]
            return _dedup_preserve_order(filtered)
        except Exception:
            return None

    def _parse_editor_tags(self) -> List[str]:
        raw = ""
        try:
            raw = str(self._editor.text or "") if self._editor else ""
        except Exception:
            raw = ""
        lines = [t.strip() for t in raw.replace("\r\n", "\n").split("\n")]
        return _dedup_preserve_order([t for t in lines if t])

    def on_button_pressed(self, event: Button.Pressed) -> None:
        if event.button.id == "tags-close":
            self.dismiss(None)
            return
        if event.button.id == "tags-save":
            self._save_tags()

    def _save_tags(self) -> None:
        desired = self._parse_editor_tags()
        current = _dedup_preserve_order(list(self._original_tags or []))

        desired_set = {t.lower()
                       for t in desired}
        current_set = {t.lower()
                       for t in current}

        to_add = [t for t in desired if t.lower() not in current_set]
        to_del = [t for t in current if t.lower() not in desired_set]

        if not to_add and not to_del:
            self._set_status("No changes")
            return

        self._set_status("Saving…")
        self._save_tags_background(to_add, to_del, desired)

    @work(thread=True)
    def _save_tags_background(
        self,
        to_add: List[str],
        to_del: List[str],
        desired: List[str]
    ) -> None:
        app = self.app  # PipelineHubApp
        def _log_message(msg: str) -> None:
            if not msg:
                return
            try:
                app.call_from_thread(app._append_log_line, msg)
            except Exception:
                pass

        def _log_pipeline_command(stage: str, cmd: str) -> None:
            if not cmd:
                return
            _log_message(f"tags-save: {stage}: {cmd}")

        def _log_pipeline_result(stage: str, result: PipelineRunResult | None) -> None:
            if result is None:
                return
            status = "success" if getattr(result, "success", False) else "failed"
            _log_message(f"tags-save: {stage} result: {status}")
            error = str(getattr(result, "error", "") or "").strip()
            if error:
                _log_message(f"tags-save: {stage} error: {error}")
            for attr in ("stdout", "stderr"):
                raw = str(getattr(result, attr, "") or "").strip()
                if not raw:
                    continue
                for line in raw.splitlines():
                    _log_message(f"tags-save: {stage} {attr}: {line}")
        try:
            runner: PipelineRunner = getattr(app, "executor")
            store_tok = json.dumps(self._store)
            query_chunk = f" -query {json.dumps(f'hash:{self._hash}')}" if self._hash else ""

            failures: List[str] = []

            if to_del:
                del_args = " ".join(json.dumps(t) for t in to_del)
                del_cmd = f"delete-tag -store {store_tok}{query_chunk} {del_args}"
                _log_pipeline_command("delete-tag", del_cmd)
                del_res = runner.run_pipeline(del_cmd, seeds=self._seeds, isolate=True)
                _log_pipeline_result("delete-tag", del_res)
                if not getattr(del_res, "success", False):
                    failures.append(
                        str(
                            getattr(del_res,
                                    "error",
                                    "") or getattr(del_res,
                                                   "stderr",
                                                   "") or "delete-tag failed"
                        ).strip()
                    )

            if to_add:
                add_args = " ".join(json.dumps(t) for t in to_add)
                add_cmd = f"add-tag -store {store_tok}{query_chunk} {add_args}"
                _log_pipeline_command("add-tag", add_cmd)
                add_res = runner.run_pipeline(add_cmd, seeds=self._seeds, isolate=True)
                _log_pipeline_result("add-tag", add_res)
                if not getattr(add_res, "success", False):
                    failures.append(
                        str(
                            getattr(add_res,
                                    "error",
                                    "") or getattr(add_res,
                                                   "stderr",
                                                   "") or "add-tag failed"
                        ).strip()
                    )

            if failures:
                msg = failures[0]
                try:
                    app.call_from_thread(self._set_status, f"Error: {msg}")
                except Exception:
                    self._set_status(f"Error: {msg}")
                return

            reloaded = self._fetch_tags_from_store()
            refreshed = reloaded is not None
            tags_to_show = list(reloaded or []) if refreshed else list(desired)
            self._original_tags = list(tags_to_show)
            try:
                app.call_from_thread(self._apply_loaded_tags, tags_to_show)
            except Exception:
                self._apply_loaded_tags(tags_to_show)

            def _refresh_overlay() -> None:
                try:
                    app.refresh_tag_overlay(
                        self._store,
                        self._hash,
                        tags_to_show,
                        self._seeds,
                    )
                except Exception:
                    pass

            try:
                app.call_from_thread(_refresh_overlay)
            except Exception:
                _refresh_overlay()

            status_msg = f"Saved (+{len(to_add)}, -{len(to_del)})"
            if refreshed:
                status_msg += f"; loaded {len(tags_to_show)} tag(s)"
            try:
                app.call_from_thread(self._set_status, status_msg)
            except Exception:
                self._set_status(status_msg)
        except Exception as exc:
            try:
                app.call_from_thread(
                    self._set_status,
                    f"Error: {type(exc).__name__}: {exc}"
                )
            except Exception:
                self._set_status(f"Error: {type(exc).__name__}: {exc}")


class PipelineHubApp(App):
    """Textual front-end that executes cmdlet pipelines inline."""

    CSS_PATH = str(TUI_DIR / "tui.tcss")
    BINDINGS = [
        Binding("ctrl+enter",
                "run_pipeline",
                "Run Pipeline"),
        Binding("f5",
                "refresh_workers",
                "Refresh Workers"),
        Binding("ctrl+l",
                "focus_command",
                "Focus Input",
                show=False),
        Binding("ctrl+g",
                "focus_logs",
                "Focus Logs",
                show=False),
    ]

    def __init__(self) -> None:
        super().__init__()
        self.executor = PipelineRunner()
        self.result_items: List[Any] = []
        self.log_lines: List[str] = []
        self.command_input: Optional[Input] = None
        self.store_select: Optional[Select] = None
        self.path_input: Optional[Input] = None
        self.log_output: Optional[TextArea] = None
        self.results_table: Optional[DataTable] = None
        self.worker_table: Optional[DataTable] = None
        self.status_panel: Optional[Static] = None
        self.current_result_table: Optional[ResultTable] = None
        self.suggestion_list: Optional[OptionList] = None
        self._cmdlet_names: List[str] = []
        self._pipeline_running = False
        self._pipeline_worker: Any = None
        self._selected_row_index: int = 0

    # ------------------------------------------------------------------
    # Layout
    # ------------------------------------------------------------------
    def compose(self) -> ComposeResult:  # noqa: D401 - Textual compose hook
        yield Header(show_clock=True)
        with Container(id="app-shell"):
            with Vertical(id="command-pane"):
                with Horizontal(id="command-row"):
                    yield Input(
                        placeholder="Enter pipeline command...",
                        id="pipeline-input"
                    )
                    yield Button("Run", id="run-button")
                    yield Button("Tags", id="tags-button")
                    yield Button("Metadata", id="metadata-button")
                    yield Button("Relationships", id="relationships-button")
                    yield Static("Ready", id="status-panel")
                yield OptionList(id="cmd-suggestions")

            with Vertical(id="results-pane"):
                yield Label("Results", classes="section-title")
                yield DataTable(id="results-table")

            with Vertical(id="bottom-pane"):
                yield Label("Store + Output", classes="section-title")
                with Horizontal(id="store-row"):
                    yield Select([], id="store-select")
                    yield Input(placeholder="Output path (optional)", id="output-path")

                with Horizontal(id="logs-workers-row"):
                    with Vertical(id="logs-pane"):
                        yield Label("Logs", classes="section-title")
                        yield TextArea(id="log-output", read_only=True)

                    with Vertical(id="workers-pane"):
                        yield Label("Workers", classes="section-title")
                        yield DataTable(id="workers-table")
        yield Footer()

    def on_mount(self) -> None:
        self.command_input = self.query_one("#pipeline-input", Input)
        self.status_panel = self.query_one("#status-panel", Static)
        self.results_table = self.query_one("#results-table", DataTable)
        self.worker_table = self.query_one("#workers-table", DataTable)
        self.log_output = self.query_one("#log-output", TextArea)
        self.store_select = self.query_one("#store-select", Select)
        self.path_input = self.query_one("#output-path", Input)
        self.suggestion_list = self.query_one("#cmd-suggestions", OptionList)

        if self.suggestion_list:
            self.suggestion_list.display = False

        if self.results_table:
            self.results_table.cursor_type = "row"
            self.results_table.zebra_stripes = True
            self.results_table.add_columns("Row", "Title", "Source", "File")
        if self.worker_table:
            self.worker_table.add_columns("ID", "Type", "Status", "Details")

        # Initialize the store choices cache at startup (filters disabled stores)
        try:
            from cmdlet._shared import SharedArgs
            from SYS.config import load_config
            config = load_config()
            SharedArgs._refresh_store_choices_cache(config)
        except Exception:
            pass

        self._populate_store_options()
        self._load_cmdlet_names()
        if self.executor.worker_manager:
            self.set_interval(2.0, self.refresh_workers)
        self.refresh_workers()
        if self.command_input:
            self.command_input.focus()

    # ------------------------------------------------------------------
    # Actions
    # ------------------------------------------------------------------
    def action_focus_command(self) -> None:
        if self.command_input:
            self.command_input.focus()

    def action_focus_logs(self) -> None:
        if self.log_output:
            self.log_output.focus()

    def action_run_pipeline(self) -> None:
        if self._pipeline_running:
            # Self-heal if the background worker already stopped (e.g. error in thread).
            worker = self._pipeline_worker
            try:
                is_running = bool(getattr(worker, "is_running", False))
            except Exception:
                is_running = True
            if (worker is None) or (not is_running):
                self._pipeline_running = False
                self._pipeline_worker = None
            else:
                self.notify("Pipeline already running", severity="warning", timeout=3)
                return
        if not self.command_input:
            return
        pipeline_text = self.command_input.value.strip()
        if not pipeline_text:
            self.notify("Enter a pipeline to run", severity="warning", timeout=3)
            return

        pipeline_text = self._apply_store_path_and_tags(pipeline_text)

        self._pipeline_running = True
        self._set_status("Running…", level="info")
        self._clear_log()
        self._append_log_line(f"$ {pipeline_text}")
        self._clear_results()
        self._pipeline_worker = self._run_pipeline_background(pipeline_text)

    @on(Input.Changed, "#pipeline-input")
    def on_pipeline_input_changed(self, event: Input.Changed) -> None:
        text = str(event.value or "")
        self._update_suggestions(text)
        self._update_syntax_status(text)

    @on(OptionList.OptionSelected, "#cmd-suggestions")
    def on_suggestion_selected(self, event: OptionList.OptionSelected) -> None:
        if not self.command_input or not self.suggestion_list:
            return
        try:
            suggestion = str(event.option.prompt)
        except Exception:
            return
        new_text = self._apply_suggestion_to_text(
            str(self.command_input.value or ""),
            suggestion
        )
        self.command_input.value = new_text
        self.suggestion_list.display = False
        self.command_input.focus()

    def action_refresh_workers(self) -> None:
        self.refresh_workers()

    # ------------------------------------------------------------------
    # Event handlers
    # ------------------------------------------------------------------
    def on_button_pressed(self, event: Button.Pressed) -> None:
        if event.button.id == "run-button":
            self.action_run_pipeline()
        elif event.button.id == "tags-button":
            self._open_tags_popup()
        elif event.button.id == "metadata-button":
            self._open_metadata_popup()
        elif event.button.id == "relationships-button":
            self._open_relationships_popup()

    def on_input_submitted(self, event: Input.Submitted) -> None:
        if event.input.id == "pipeline-input":
            self.action_run_pipeline()

    def on_key(self, event: Key) -> None:
        # Make Tab accept autocomplete when typing commands.
        if event.key != "tab":
            return
        if not self.command_input or not self.command_input.has_focus:
            return
        suggestion = self._get_first_suggestion()
        if not suggestion:
            return

        self.command_input.value = self._apply_suggestion_to_text(
            str(self.command_input.value or ""),
            suggestion
        )
        if self.suggestion_list:
            self.suggestion_list.display = False
        event.prevent_default()
        event.stop()

    def _get_first_suggestion(self) -> str:
        if not self.suggestion_list or not bool(getattr(self.suggestion_list,
                                                        "display",
                                                        False)):
            return ""
        # Textual OptionList API differs across versions; handle best-effort.
        try:
            options = list(getattr(self.suggestion_list, "options", []) or [])
            if options:
                first = options[0]
                return str(getattr(first, "prompt", "") or "")
        except Exception:
            pass
        return ""

    def _populate_store_options(self) -> None:
        """Populate the store dropdown from the configured Store registry."""
        if not self.store_select:
            return
        try:
            cfg = load_config() or {}
        except Exception:
            cfg = {}

        stores: List[str] = []
        try:
            stores = StoreRegistry(config=cfg, suppress_debug=True).list_backends()
        except Exception:
            stores = []

        # Always offer a reasonable default even if config is missing.
        if "local" not in [s.lower() for s in stores]:
            stores = ["local", *stores]

        options = [(name, name) for name in stores]
        try:
            self.store_select.set_options(options)
            if options:
                current = getattr(self.store_select, "value", None)
                # Textual Select uses a sentinel for "no selection".
                if (current is None) or (current == "") or (current is Select.BLANK):
                    self.store_select.value = options[0][1]
        except Exception:
            pass

    def _get_selected_store(self) -> Optional[str]:
        if not self.store_select:
            return None
        try:
            value = getattr(self.store_select, "value", None)
        except Exception:
            return None

        if value is None or value is Select.BLANK:
            return None
        try:
            text = str(value).strip()
        except Exception:
            return None

        if not text or text == "Select.BLANK":
            return None
        return text

    def _apply_store_path_and_tags(self, pipeline_text: str) -> str:
        """Apply store/path/tags UI fields to the pipeline text.

        Rules (simple + non-destructive):
        - If output path is set and the first stage is download-file and has no -path/--path, append -path.
        - If a store is selected and pipeline has no add-file stage, append add-file -store <store>.
        """
        base = str(pipeline_text or "").strip()
        if not base:
            return base

        selected_store = self._get_selected_store()

        output_path = ""
        if self.path_input:
            try:
                output_path = str(self.path_input.value or "").strip()
            except Exception:
                output_path = ""

        stages = [s.strip() for s in base.split("|") if s.strip()]
        if not stages:
            return base

        # Identify first stage command name for conservative auto-augmentation.
        first_stage_cmd = ""
        try:
            first_stage_cmd = (
                str(stages[0].split()[0]).replace("_",
                                                  "-").strip().lower()
                if stages[0].split() else ""
            )
        except Exception:
            first_stage_cmd = ""

        # Apply -path to download-file first stage (only if missing)
        if output_path:
            first = stages[0]
            low = first.lower()
            if low.startswith("download-file"
                              ) and " -path" not in low and " --path" not in low:
                stages[0] = f"{first} -path {json.dumps(output_path)}"

        joined = " | ".join(stages)

        low_joined = joined.lower()

        # Only auto-append add-file for download pipelines.
        should_auto_add_file = bool(
            selected_store and ("add-file" not in low_joined) and (
                first_stage_cmd
                in {"download-file"}
            )
        )

        if should_auto_add_file:
            store_token = json.dumps(selected_store)
            joined = f"{joined} | add-file -store {store_token}"

        return joined

    def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
        if not self.results_table or event.control is not self.results_table:
            return
        index = int(event.cursor_row or 0)
        if index < 0:
            index = 0
        self._selected_row_index = index

    # ------------------------------------------------------------------
    # Pipeline execution helpers
    # ------------------------------------------------------------------
    @work(exclusive=True, thread=True)
    def _run_pipeline_background(self, pipeline_text: str) -> None:
        try:
            run_result = self.executor.run_pipeline(
                pipeline_text,
                on_log=self._log_from_worker
            )
        except Exception as exc:
            # Ensure the UI never gets stuck in "running" state.
            run_result = PipelineRunResult(
                pipeline=str(pipeline_text or ""),
                success=False,
                error=f"{type(exc).__name__}: {exc}",
                stderr=f"{type(exc).__name__}: {exc}",
            )
        self.call_from_thread(self._on_pipeline_finished, run_result)

    def _on_pipeline_finished(self, run_result: PipelineRunResult) -> None:
        self._pipeline_running = False
        self._pipeline_worker = None
        status_level = "success" if run_result.success else "error"
        status_text = "Completed" if run_result.success else "Failed"
        self._set_status(status_text, level=status_level)

        if not run_result.success:
            self.notify(
                run_result.error or "Pipeline failed",
                severity="error",
                timeout=6
            )
        else:
            self.notify("Pipeline completed", timeout=3)

        if run_result.stdout.strip():
            self._append_log_line("stdout:")
            self._append_block(run_result.stdout)
        if run_result.stderr.strip():
            self._append_log_line("stderr:")
            self._append_block(run_result.stderr)

        for stage in run_result.stages:
            summary = f"[{stage.status}] {stage.name} -> {len(stage.emitted)} item(s)"
            if stage.error:
                summary += f" ({stage.error})"
            self._append_log_line(summary)

        emitted = run_result.emitted
        if isinstance(emitted, list):
            self.result_items = emitted
        elif emitted:
            self.result_items = [emitted]
        else:
            self.result_items = []

        self.current_result_table = run_result.result_table
        self._populate_results_table()
        self.refresh_workers()
        if self.result_items:
            self._selected_row_index = 0

    def _log_from_worker(self, message: str) -> None:
        self.call_from_thread(self._append_log_line, message)

    # ------------------------------------------------------------------
    # UI helpers
    # ------------------------------------------------------------------
    def _populate_results_table(self) -> None:
        if not self.results_table:
            return
        self.results_table.clear(columns=True)

        if self.current_result_table and self.current_result_table.rows:
            # Use ResultTable headers from the first row
            first_row = self.current_result_table.rows[0]
            headers = ["#"] + [col.name for col in first_row.columns]
            self.results_table.add_columns(*headers)

            rows = self.current_result_table.to_datatable_rows()
            for idx, row_values in enumerate(rows, 1):
                self.results_table.add_row(str(idx), *row_values, key=str(idx - 1))
        else:
            # Fallback or empty state
            self.results_table.add_columns("Row", "Title", "Source", "File")
            if not self.result_items:
                self.results_table.add_row("—", "No results", "", "")
                return

            # Fallback for items without a table
            for idx, item in enumerate(self.result_items, start=1):
                self.results_table.add_row(
                    str(idx),
                    str(item),
                    "—",
                    "—",
                    key=str(idx - 1)
                )

    def refresh_tag_overlay(self,
                             store_name: str,
                             file_hash: str,
                             tags: List[str],
                             subject: Any) -> None:
        """Update the shared get-tag overlay after manual tag edits."""
        if not store_name or not file_hash:
            return
        try:
            from cmdlet.get_tag import _emit_tags_as_table
        except Exception:
            return

        try:
            cfg = load_config() or {}
        except Exception:
            cfg = {}

        payload_subject = subject if subject is not None else None
        if not isinstance(payload_subject, dict):
            payload_subject = {
                "store": store_name,
                "hash": file_hash,
            }

        try:
            _emit_tags_as_table(
                list(tags),
                file_hash=file_hash,
                store=store_name,
                config=cfg,
                subject=payload_subject,
            )
        except Exception:
            pass

    def _load_cmdlet_names(self) -> None:
        try:
            ensure_registry_loaded()
            names = list_cmdlet_names() or []
            self._cmdlet_names = sorted(
                {str(n).replace("_",
                                "-")
                 for n in names if str(n).strip()}
            )
        except Exception:
            self._cmdlet_names = []

    def _update_syntax_status(self, text: str) -> None:
        if self._pipeline_running:
            return
        raw = str(text or "").strip()
        if not raw:
            self._set_status("Ready", level="info")
            return
        try:
            err = validate_pipeline_text(raw)
        except Exception:
            err = None
        if err:
            self._set_status(err.message, level="error")
        else:
            self._set_status("Ready", level="info")

    def _update_suggestions(self, text: str) -> None:
        if not self.suggestion_list:
            return
        raw = str(text or "")
        prefix = self._current_cmd_prefix(raw)
        if not prefix:
            self.suggestion_list.display = False
            return

        pref_low = prefix.lower()
        matches = [n for n in self._cmdlet_names if n.lower().startswith(pref_low)]
        matches = matches[:10]

        if not matches:
            self.suggestion_list.display = False
            return

        try:
            self.suggestion_list.clear_options()  # type: ignore[attr-defined]
        except Exception:
            try:
                # Fallback for older/newer Textual APIs.
                self.suggestion_list.options = []  # type: ignore[attr-defined]
            except Exception:
                pass

        try:
            self.suggestion_list.add_options(
                [Option(m) for m in matches]
            )  # type: ignore[attr-defined]
        except Exception:
            try:
                self.suggestion_list.options = [
                    Option(m) for m in matches
                ]  # type: ignore[attr-defined]
            except Exception:
                pass

        self.suggestion_list.display = True

    @staticmethod
    def _current_cmd_prefix(text: str) -> str:
        """Best-effort prefix for cmdlet name completion.

        Completes the token immediately after start-of-line or a '|'.
        """
        raw = str(text or "")
        # Find the segment after the last pipe.
        segment = raw.split("|")[-1]
        # Remove leading whitespace.
        segment = segment.lstrip()
        if not segment:
            return ""
        # Only complete the first token of the segment.
        m = re.match(r"([A-Za-z0-9_\-]*)", segment)
        return m.group(1) if m else ""

    @staticmethod
    def _apply_suggestion_to_text(text: str, suggestion: str) -> str:
        raw = str(text or "")
        parts = raw.split("|")
        if not parts:
            return suggestion
        last = parts[-1]
        # Preserve leading spaces after the pipe.
        leading = "".join(ch for ch in last if ch.isspace())
        trimmed = last.lstrip()
        # Replace first token in last segment.
        replaced = re.sub(r"^[A-Za-z0-9_\-]*", suggestion, trimmed)
        parts[-1] = leading + replaced
        return "|".join(parts)

    def _resolve_selected_item(
        self
    ) -> Tuple[Optional[Any],
               Optional[str],
               Optional[str]]:
        """Return (item, store_name, hash) for the currently selected row."""
        index = int(getattr(self, "_selected_row_index", 0) or 0)
        if index < 0:
            index = 0

        item: Any = None
        row_payload: Any = None
        row = None
        column_store: Optional[str] = None
        column_hash: Optional[str] = None

        # Prefer mapping displayed table row -> source item.
        if self.current_result_table and 0 <= index < len(
                getattr(self.current_result_table,
                        "rows",
                        []) or []):
            row = self.current_result_table.rows[index]
            row_payload = getattr(row, "payload", None)
            src_idx = getattr(row, "source_index", None)
            if isinstance(src_idx, int) and 0 <= src_idx < len(self.result_items):
                item = self.result_items[src_idx]
            for col in getattr(row, "columns", []) or []:
                name = str(getattr(col, "name", "") or "").strip().lower()
                value = str(getattr(col, "value", "") or "").strip()
                if not column_store and name in {"store", "storage", "source", "table"}:
                    column_store = value
                if not column_hash and name in {"hash", "hash_hex", "file_hash", "sha256"}:
                    column_hash = value

        if item is None and 0 <= index < len(self.result_items):
            item = self.result_items[index]

        def _pick_from_candidates(
            candidates: List[Any], extractor: Callable[[Any], str]
        ) -> str:
            for candidate in candidates:
                if candidate is None:
                    continue
                try:
                    value = extractor(candidate)
                except Exception:
                    value = ""
                if value and str(value).strip():
                    return str(value).strip()
            return ""

        candidate_sources: List[Any] = []
        if row_payload is not None:
            candidate_sources.append(row_payload)
        if item is not None:
            candidate_sources.append(item)

        store_name = _pick_from_candidates(candidate_sources, extract_store_value)
        file_hash = _pick_from_candidates(candidate_sources, extract_hash_value)

        if not store_name and column_store:
            store_name = column_store
        if not file_hash and column_hash:
            file_hash = column_hash

        store_text = str(store_name).strip() if store_name else ""
        hash_text = str(file_hash).strip() if file_hash else ""

        if not store_text:
            # Fallback to UI store selection when item doesn't carry it.
            store_text = self._get_selected_store() or ""

        final_item = row_payload if row_payload is not None else item
        if final_item is None and (store_text or hash_text):
            fallback: Dict[str, str] = {}
            if store_text:
                fallback["store"] = store_text
            if hash_text:
                fallback["hash"] = hash_text
            final_item = fallback

        return final_item, (store_text or None), (hash_text or None)

    def _open_tags_popup(self) -> None:
        if self._pipeline_running:
            self.notify("Pipeline already running", severity="warning", timeout=3)
            return
        item, store_name, file_hash = self._resolve_selected_item()
        if item is None:
            self.notify("No selected item", severity="warning", timeout=3)
            return
        if not store_name:
            self.notify("Selected item missing store", severity="warning", timeout=4)
            return

        seeds: Any = item
        if isinstance(item, dict):
            seeds = dict(item)
            try:
                if store_name and not str(seeds.get("store") or "").strip():
                    seeds["store"] = store_name
            except Exception:
                pass
            try:
                if file_hash and not str(seeds.get("hash") or "").strip():
                    seeds["hash"] = file_hash
            except Exception:
                pass

        self.push_screen(
            TagEditorPopup(seeds=seeds,
                           store_name=store_name,
                           file_hash=file_hash)
        )

    def _open_metadata_popup(self) -> None:
        item, _store_name, _file_hash = self._resolve_selected_item()
        if item is None:
            self.notify("No selected item", severity="warning", timeout=3)
            return
        text = ""
        idx = int(getattr(self, "_selected_row_index", 0) or 0)
        if self.current_result_table and 0 <= idx < len(
                getattr(self.current_result_table,
                        "rows",
                        []) or []):
            row = self.current_result_table.rows[idx]
            lines = [
                f"{col.name}: {col.value}" for col in getattr(row, "columns", []) or []
            ]
            text = "\n".join(lines)
        elif isinstance(item, dict):
            try:
                text = json.dumps(item, indent=2, ensure_ascii=False)
            except Exception:
                text = str(item)
        else:
            text = str(item)
        self.push_screen(TextPopup(title="Metadata", text=text))

    def _open_relationships_popup(self) -> None:
        item, _store_name, _file_hash = self._resolve_selected_item()
        if item is None:
            self.notify("No selected item", severity="warning", timeout=3)
            return

        relationships = None
        if isinstance(item, dict):
            relationships = item.get("relationships") or item.get("relationship")
        else:
            relationships = getattr(item, "relationships", None)
            if not relationships:
                relationships = getattr(item, "get_relationships", lambda: None)()

        if not relationships:
            self.push_screen(TextPopup(title="Relationships", text="No relationships"))
            return

        lines: List[str] = []
        if isinstance(relationships, dict):
            for rel_type, value in relationships.items():
                if isinstance(value, list):
                    if not value:
                        lines.append(f"{rel_type}: (empty)")
                    for v in value:
                        lines.append(f"{rel_type}: {v}")
                else:
                    lines.append(f"{rel_type}: {value}")
        else:
            lines.append(str(relationships))
        self.push_screen(TextPopup(title="Relationships", text="\n".join(lines)))

    def _clear_log(self) -> None:
        self.log_lines = []
        if self.log_output:
            self.log_output.text = ""

    def _append_log_line(self, line: str) -> None:
        self.log_lines.append(line)
        if len(self.log_lines) > 500:
            self.log_lines = self.log_lines[-500:]
        if self.log_output:
            self.log_output.text = "\n".join(self.log_lines)

    def _append_block(self, text: str) -> None:
        for line in text.strip().splitlines():
            self._append_log_line(f"  {line}")

    def _clear_results(self) -> None:
        self.result_items = []
        if self.results_table:
            self.results_table.clear()
        self._selected_row_index = 0

    def _set_status(self, message: str, *, level: str = "info") -> None:
        if not self.status_panel:
            return
        for css in ("status-info", "status-success", "status-error"):
            self.status_panel.remove_class(css)
        css_class = f"status-{level if level in {'success', 'error'} else 'info'}"
        self.status_panel.add_class(css_class)
        self.status_panel.update(message)

    def refresh_workers(self) -> None:
        if not self.worker_table:
            return
        manager = self.executor.worker_manager
        self.worker_table.clear()
        if manager is None:
            self.worker_table.add_row("—", "—", "—", "Worker manager unavailable")
            return
        workers = manager.get_active_workers()
        if not workers:
            self.worker_table.add_row("—", "—", "—", "No active workers")
            return
        for worker in workers:
            worker_id = str(worker.get("worker_id") or worker.get("id") or "?")[:8]
            worker_type = str(worker.get("worker_type") or worker.get("type") or "?")
            status = str(worker.get("status") or worker.get("result") or "running")
            details = (
                worker.get("current_step") or worker.get("description")
                or worker.get("pipe") or ""
            )
            self.worker_table.add_row(worker_id, worker_type, status, str(details)[:80])


if __name__ == "__main__":
    PipelineHubApp().run()
