from __future__ import annotations

import asyncio
import csv
import json
import os
import shutil
import subprocess
import sys
from datetime import datetime
from pathlib import Path

from rich.markdown import Markdown
from textual import on
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.events import Click, Key, Resize
from textual.css.query import NoMatches
from textual.widgets import DataTable, Header, Input, RichLog, Static

from graham.command_feedback import command_name, command_result_feedback, command_start_feedback
from graham.commands import CommandProcessor, discover_universe_names
from graham.completion import apply_completion
from graham.graham import GrahamEngine, StockAnalysis, filter_ranked, format_metric
from graham.i18n import DisplayTranslator
from graham.llm import LLMError, ask_model, build_moat_prompt, fallback_explanation
from graham.search import matches_ticker_or_company_query
from graham.settings import UserSettings, load_persisted_language, load_user_settings, save_user_settings


class DetailsPanel(Static):
    can_focus = True


class GrahamApp(App[None]):
    TITLE = "graham"
    SUGGESTION_MAX_VISIBLE = 8
    BINDINGS = [
        Binding("ctrl+c", "quit", "Quit", show=False, priority=True),
        Binding("ctrl+q", "noop", "", show=False, priority=True),
        Binding("ctrl+d", "focus_details", "Details", show=False, priority=True),
        Binding("ctrl+p", "focus_prompt", "Prompt", show=False, priority=True),
        Binding("ctrl+l", "clear_outputs", "Clear Outputs", show=False, priority=True),
        Binding("ctrl+r", "search_tickers", "Search Tickers", show=False, priority=True),
        Binding("f1", "show_shortcuts", "Shortcuts", show=False, priority=True),
    ]
    CSS = """
    Screen {
        layout: vertical;
    }

    #center {
        height: 1fr;
        min-height: 6;
    }

    #ranking {
        width: 2fr;
        min-height: 6;
        height: 1fr;
        border: round #4c956c;
        overflow: auto;
    }

    #details {
        width: 1fr;
        min-width: 24;
        height: 1fr;
        border: round #bc4749;
        padding: 1 1;
        overflow-y: auto;
    }

    #log {
        height: 1fr;
        min-height: 4;
        border: round #386641;
        overflow: auto;
    }

    #input-wrap {
        height: auto;
        min-height: 3;
        margin-top: 1;
        padding: 0 1;
        border: round #1d3557;
    }

    #prompt {
        width: 100%;
        margin: 0;
    }

    #suggestions {
        display: none;
        max-height: 10;
        overflow-y: auto;
        border: round #6a994e;
    }

    #suggestions.visible {
        display: block;
    }

    #center.narrow {
        layout: vertical;
    }

    #ranking.narrow {
        width: 1fr;
        height: 1fr;
    }

    #details.narrow {
        width: 1fr;
        min-width: 0;
        height: 1fr;
    }

    #log.narrow {
        height: 1fr;
        min-height: 4;
    }
    """
    SORTABLE_COLUMNS = (
        "rank",
        "ticker",
        "company",
        "score",
        "rating",
        "price",
        "as_of",
        "v",
        "mos",
        "pe",
        "pb",
        "dividend",
    )
    SORT_COLUMN_ALIASES = {
        "asof": "as_of",
        "as_of": "as_of",
        "as_of_time": "as_of",
        "price_time": "as_of",
        "company_name": "company",
        "symbol": "ticker",
        "intrinsic": "v",
        "intrinsic_value": "v",
        "value": "v",
        "margin_of_safety": "mos",
        "pe_ratio": "pe",
        "pb_ratio": "pb",
        "dividend_rate": "dividend",
    }

    def __init__(self) -> None:
        super().__init__()
        self.settings: UserSettings = load_user_settings()
        self.engine = GrahamEngine(y=4.4, require_dividend=True)
        startup_language = self.settings.default_language.strip().lower() if self.settings.default_language else "en"
        if not startup_language:
            startup_language = "en"
        self.i18n = DisplayTranslator(language=startup_language)
        self.language = startup_language
        self.model = self.settings.default_model or "none"
        self.refresh_seconds = 15
        self.scan_top: int | None = None
        self.scan_min_score = 0.0
        self.selected_ticker: str | None = None
        self._current_results: list[StockAnalysis] = []
        self._suggestions: list[str] = []
        self._suggestion_index = 0
        self._suppress_suggestions_once = False
        self._prompt_history: list[str] = []
        self._prompt_history_index: int | None = None
        self._prompt_history_draft = ""
        self._details_text = self.tr("No row selected.")
        self._log_blocks: list[tuple[int, int, str]] = []
        self._refreshing = False
        self._timer = None
        self._universe_note = "sample"
        self._scan_loading = False
        self._scan_spinner_step = 0
        self._scan_spinner_frames = ["-", "\\", "|", "/"]
        self._scan_spinner_timer = None
        self._initial_scan_task: asyncio.Task[None] | None = None
        self._details_nav_mode = False
        self._sort_column = "rank"
        self._sort_reverse = False
        self._ticker_search_mode = False
        self._ticker_search_query = ""
        self._ticker_search_draft = ""
        self._pending_log_scroll_absolute_line: int | None = None
        self.score_green_min = self.settings.score_green_min
        self.score_orange_min = self.settings.score_orange_min
        self.processor = CommandProcessor(self)

    def compose(self) -> ComposeResult:
        yield Header(show_clock=True)
        with Horizontal(id="center"):
            yield DataTable(id="ranking")
            yield DetailsPanel(self._details_text, id="details")
        yield RichLog(id="log", markup=False, wrap=True)
        with Vertical(id="input-wrap"):
            yield Input(placeholder=self.tr("Type /help"), id="prompt")
            yield Static("", id="suggestions")

    async def on_mount(self) -> None:
        self._setup_table_columns()
        self._apply_responsive_layout()
        self.query_one("#prompt", Input).focus()

        default_spec = self.settings.default_universe or "sample"
        tickers, note = self.processor.resolve_universe(default_spec)
        if not tickers:
            default_spec = "sample"
            tickers, note = self.processor.resolve_universe(default_spec)
            self.settings.default_universe = default_spec
            self._save_settings()
        self.set_universe(tickers, note)
        self._timer = self.set_interval(self.refresh_seconds, self._schedule_price_refresh)
        self.write_log("Welcome to graham. Type /help")
        self.write_log(f"Default universe: {default_spec}")
        self.write_log("Initial scan started in background...")
        self.call_later(self._start_initial_scan)

    def on_resize(self, event: Resize) -> None:
        self._apply_responsive_layout()

    def _start_initial_scan(self) -> None:
        if self._initial_scan_task is not None and not self._initial_scan_task.done():
            return
        self._initial_scan_task = asyncio.create_task(self._run_initial_scan())

    def on_unmount(self) -> None:
        if self._initial_scan_task is not None and not self._initial_scan_task.done():
            self._initial_scan_task.cancel()
        if self._scan_spinner_timer is not None:
            try:
                self._scan_spinner_timer.stop()
            except Exception:
                pass
            self._scan_spinner_timer = None
        if self._timer is not None:
            try:
                self._timer.stop()
            except Exception:
                pass
            self._timer = None

    async def _run_initial_scan(self) -> None:
        try:
            await self.run_scan(top=None, min_score=0.0, refresh=self.refresh_seconds)
        except asyncio.CancelledError:
            return
        except Exception as exc:
            self.write_log(f"Initial scan error: {exc}")

    def tr(self, text: str) -> str:
        return self.i18n.tr(text)

    def set_language(self, language: str) -> tuple[bool, str]:
        success, message = self.i18n.set_language(language)
        if not success:
            return False, message

        self.language = self.i18n.language
        self.settings.default_language = self.language
        saved, save_error = self._save_settings()
        if not saved:
            return False, f"Language updated but could not persist settings: {save_error}"
        prompt = self.query_one("#prompt", Input)
        prompt.placeholder = self.tr("Type /help")
        self._setup_table_columns()
        self.refresh_table()
        return True, self.tr(message)

    def set_model(self, model: str) -> tuple[bool, str]:
        value = model.strip() or "none"
        self.model = value
        self.settings.default_model = value
        saved, save_error = self._save_settings()
        if not saved:
            return False, f"Model updated but could not persist settings: {save_error}"
        return True, f"Model saved: {value}"

    def get_rating_thresholds(self) -> tuple[float, float]:
        return self.score_green_min, self.score_orange_min

    def set_rating_thresholds(self, green: float, orange: float) -> tuple[bool, str]:
        if green > 1:
            green = green / 100.0
        if orange > 1:
            orange = orange / 100.0
        if green < 0 or orange < 0 or green > 1 or orange > 1:
            return False, "Thresholds must be in [0..1] or percentages [0..100]."
        if orange > green:
            return False, "Orange threshold must be <= green threshold."

        self.score_green_min = green
        self.score_orange_min = orange
        self.settings.score_green_min = green
        self.settings.score_orange_min = orange
        saved, save_error = self._save_settings()
        if not saved:
            return False, f"Thresholds updated but could not persist settings: {save_error}"

        self.refresh_table()
        return True, (
            f"Rating thresholds updated: green>={green:.2f}, orange>={orange:.2f}, red<{orange:.2f}"
        )

    def write_log(
        self, message: str, translate: bool = True, markdown: bool = False
    ) -> tuple[int, int] | None:
        logger = self._query_log()
        if logger is None:
            return None
        rendered = self.tr(message) if translate else message
        before_start = int(getattr(logger, "_start_line", 0))
        before_len = len(logger.lines)
        if markdown:
            logger.write(Markdown(rendered))
        else:
            logger.write(rendered)
        after_start = int(getattr(logger, "_start_line", 0))
        after_len = len(logger.lines)
        start_line = before_start + before_len
        end_line = after_start + after_len
        self._register_log_block(rendered, start_line, end_line, after_start)
        return start_line, end_line

    def _query_log(self) -> RichLog | None:
        try:
            return self.query_one("#log", RichLog)
        except NoMatches:
            return None

    def get_default_universe(self) -> str:
        return self.settings.default_universe

    def set_default_universe(self, spec: str) -> tuple[bool, str]:
        tickers, note = self.processor.resolve_universe(spec)
        if not tickers:
            return False, f"Cannot set default universe. {note}"

        self.settings.default_universe = spec
        saved, save_error = self._save_settings()
        if not saved:
            return False, f"Default universe updated in memory but persistence failed: {save_error}"

        self.set_universe(tickers, note, remember_spec=spec)
        return True, f"Default universe set to: {spec} ({len(tickers)} tickers)"

    def available_universes(self) -> list[str]:
        return discover_universe_names()

    def current_tickers(self) -> list[str]:
        if self._current_results:
            return [item.ticker for item in self._current_results]
        return list(self.engine.universe)

    def set_company_name_overrides(self, overrides: dict[str, str] | None) -> None:
        self.engine.set_company_name_overrides(overrides)

    def set_universe(
        self,
        tickers: list[str],
        note: str,
        remember_spec: str | None = None,
        company_overrides: dict[str, str] | None = None,
    ) -> None:
        cleaned = self.engine.set_universe(tickers)
        self.set_company_name_overrides(company_overrides)
        self._universe_note = note
        if remember_spec is not None:
            self.settings.default_universe = remember_spec
            self._save_settings()
        self.write_log(f"Active universe: {len(cleaned)} tickers ({note})")

    async def run_scan(self, top: int | None, min_score: float, refresh: int | None) -> None:
        self.scan_top = top
        self.scan_min_score = min_score
        if refresh is not None:
            self.refresh_seconds = max(3, refresh)
            self._reset_timer()

        self.write_log("Running fundamentals scan...")
        self._set_scan_loading(True)
        try:
            ranked = await asyncio.to_thread(self.engine.scan_fundamentals)
            self._current_results = filter_ranked(ranked, top=self.scan_top, min_score=self.scan_min_score)
            self.refresh_table()

            if not self._current_results:
                self.write_log("No results. Try /scan --min-score 0")
            else:
                self.write_log(f"Scan completed: {len(self._current_results)} results")
        finally:
            self._set_scan_loading(False)

    async def explain_ticker(self, ticker: str, question: str) -> str:
        analysis = next((item for item in self.engine.analyses if item.ticker == ticker), None)
        if analysis is None:
            return f"Ticker not found: {ticker}"

        criteria_lines = [
            f"- {criterion.index}. {criterion.label}: {criterion.status} ({criterion.note})"
            for criterion in analysis.criteria
        ]

        if self.model != "none":
            system_prompt = (
                "You are a concise value investing analyst. "
                "Explain Benjamin Graham's 7 rules carefully and call out N/A values."
            )
            user_prompt = (
                f"Ticker: {analysis.ticker}\n"
                f"Score: {analysis.score:.2f}\n"
                f"Price: {format_metric(analysis.price)}\n"
                f"V: {format_metric(analysis.intrinsic_value)}\n"
                f"MoS: {format_metric(analysis.mos, percentage=True)}\n"
                f"Question: {question or 'Summarize the situation'}\n"
                + "\n".join(criteria_lines)
            )
            try:
                response = await asyncio.to_thread(ask_model, self.model, system_prompt, user_prompt)
                self.write_log(f"[LLM {self.model} /explain {analysis.ticker}]", translate=False)
                llm_block = self.write_log(response, translate=False, markdown=True)
                if llm_block is not None:
                    self._queue_log_scroll_to_absolute_line(llm_block[0])
                return self.tr("LLM explanation written to log.")
            except LLMError as exc:
                fallback = fallback_explanation(
                    ticker=analysis.ticker,
                    question=question,
                    score=analysis.score,
                    mos=analysis.mos,
                    criteria_lines=criteria_lines,
                )
                self.write_log(f"LLM error: {exc}")
                self.write_log(fallback)
                return self.tr("LLM error, deterministic fallback written to log.")

        fallback = fallback_explanation(
            ticker=analysis.ticker,
            question=question,
            score=analysis.score,
            mos=analysis.mos,
            criteria_lines=criteria_lines,
        )
        self.write_log(fallback)
        return self.tr("Explanation written to log.")

    async def moat_ticker(self, ticker: str) -> str:
        if self.model == "none":
            return self.tr("LLM disabled. Set a model first with /model [model-name].")

        symbol = ticker.strip().upper()
        if not symbol:
            return self.tr("Usage: /moat TICKER")

        self.write_log(f"Generating moat analysis for {symbol} with model {self.model}...", translate=False)
        language_for_prompt = load_persisted_language(self.language)
        prompt = build_moat_prompt(symbol, language_for_prompt)
        try:
            response = await asyncio.to_thread(ask_model, self.model, "", prompt, 60)
        except LLMError as exc:
            self.write_log(f"LLM error: {exc}")
            return self.tr("LLM error while generating moat analysis.")

        self.write_log(f"[LLM {self.model} /moat {symbol}]", translate=False)
        llm_block = self.write_log(response, translate=False, markdown=True)
        if llm_block is not None:
            self._queue_log_scroll_to_absolute_line(llm_block[0])
        return self.tr("LLM moat analysis written to log.")

    def export_results(self, export_format: str) -> str:
        export_dir = Path("exports")
        export_dir.mkdir(parents=True, exist_ok=True)

        stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        output = export_dir / f"graham_scan_{stamp}.{export_format}"

        payload = [
            {
                "ticker": item.ticker,
                "company_name": item.company_name,
                "score": item.score,
                "price": item.price,
                "price_time": item.price_time,
                "intrinsic_value": item.intrinsic_value,
                "mos": item.mos,
                "pe": item.pe,
                "pb": item.pb,
                "dividend_rate": item.dividend_rate,
            }
            for item in self._current_results
        ]

        if export_format == "json":
            output.write_text(json.dumps(payload, indent=2), encoding="utf-8")
            return str(output)

        with output.open("w", newline="", encoding="utf-8") as handle:
            writer = csv.DictWriter(
                handle,
                fieldnames=[
                    "ticker",
                    "company_name",
                    "score",
                    "price",
                    "price_time",
                    "intrinsic_value",
                    "mos",
                    "pe",
                    "pb",
                    "dividend_rate",
                ],
            )
            writer.writeheader()
            writer.writerows(payload)
        return str(output)

    @on(DataTable.RowHighlighted, "#ranking")
    def on_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
        row_key = event.row_key
        if row_key is None:
            return
        ticker = str(row_key.value)
        analysis = next((item for item in self._current_results if item.ticker == ticker), None)
        if analysis is None:
            return
        self._show_details(analysis)

    @on(DataTable.HeaderSelected, "#ranking")
    def on_header_selected(self, event: DataTable.HeaderSelected) -> None:
        column_index = self._extract_header_column_index(event)
        if column_index is None:
            return
        column = self._column_id_for_index(column_index)
        if column is None:
            return

        success, message = self.set_sort(column)
        if success:
            self.write_log(message, translate=False)

    @on(Click)
    def on_click(self, event: Click) -> None:
        logger = self.query_one("#log", RichLog)
        details = self.query_one("#details", Static)

        log_offset = event.get_content_offset(logger)
        if log_offset is not None:
            scroll_y = int(getattr(logger, "scroll_y", 0))
            start_line = int(getattr(logger, "_start_line", 0))
            absolute_line = start_line + max(0, int(log_offset.y) + scroll_y)
            block = self._find_log_block(absolute_line)
            if not block:
                self.notify("No output block found to copy.", severity="warning")
                return
            self._copy_block_to_clipboard(block, "Output block copied to clipboard.")
            return

        details_offset = event.get_content_offset(details)
        if details_offset is None:
            return
        if not self._details_text.strip():
            self.notify("No details block found to copy.", severity="warning")
            return
        self._copy_block_to_clipboard(self._details_text, "Details block copied to clipboard.")

    @on(Input.Changed, "#prompt")
    def on_input_changed(self, event: Input.Changed) -> None:
        if self._ticker_search_mode:
            self._ticker_search_query = event.value.strip().upper()
            self._suggestions = []
            self._hide_suggestions()
            self.refresh_table()
            return

        if self._suppress_suggestions_once:
            self._suppress_suggestions_once = False
            return
        self._prompt_history_index = None
        self._prompt_history_draft = ""
        self._suggestions = self.processor.suggestions(event.value)
        self._suggestion_index = 0
        self._render_suggestions()

    @on(Input.Submitted, "#prompt")
    async def on_input_submitted(self, event: Input.Submitted) -> None:
        if self._ticker_search_mode:
            query = event.value.strip().upper()
            self._ticker_search_query = query
            self.refresh_table()
            self._stop_ticker_search(keep_filter=bool(query))
            matches = len(self._filtered_sorted_results())
            if query:
                self.write_log(f"Ticker filter: {query} ({matches} matches)", translate=False)
            else:
                self.write_log("Ticker filter cleared.", translate=False)
            return

        line = event.value.strip()
        if self._suggestions:
            self._normalize_suggestion_index()
            selected = self._suggestions[self._suggestion_index]
            if line != selected:
                self._apply_suggestion()
                line = self.query_one("#prompt", Input).value.strip()
            else:
                self._hide_suggestions()

        self.query_one("#prompt", Input).value = ""
        self._hide_suggestions()

        if not line:
            return

        self._push_prompt_history(line)
        self.write_log(f"> {line}")

        if line.startswith("/"):
            started_message = command_start_feedback(line)
            if started_message:
                self.notify(started_message, severity="information")
                if command_name(line) != "/moat":
                    self.write_log(started_message, translate=False)
            try:
                response = await self.processor.execute(line)
            except Exception as exc:
                response = f"Command error: {exc}"
            if response:
                self.write_log(response, translate=False)
                result_feedback = command_result_feedback(line, response)
                if result_feedback is not None:
                    message, severity = result_feedback
                    self.notify(message, severity=severity)
            self._apply_pending_log_scroll()
            return

        if self.selected_ticker:
            response = await self.explain_ticker(self.selected_ticker, line)
            self.write_log(response, translate=False)
            self._apply_pending_log_scroll()
            return

        self.write_log("No ticker selected. Type /help")

    def on_key(self, event: Key) -> None:
        if self._details_nav_mode:
            if event.key in {"up", "down", "pageup", "pagedown", "home", "end"}:
                self._scroll_details(event.key)
                event.stop()
                return
            if event.key == "escape":
                self.action_focus_prompt()
                event.stop()
                return

        prompt = self.query_one("#prompt", Input)
        if self.focused is not prompt:
            return
        if self._ticker_search_mode:
            if event.key == "escape":
                self._stop_ticker_search(keep_filter=False)
                self.notify(self.tr("Ticker search cancelled."), severity="information")
                event.prevent_default()
                event.stop()
            return
        history_active = self._prompt_history_index is not None
        if not history_active and not self._suggestions and prompt.value.strip().startswith("/"):
            regenerated = self.processor.suggestions(prompt.value)
            if regenerated:
                self._suggestions = regenerated
                self._suggestion_index = 0
                self._render_suggestions()

        if event.key == "down":
            if history_active:
                self._history_next(prompt)
                event.prevent_default()
                event.stop()
                return
            if not self._suggestions and self._history_next(prompt):
                event.prevent_default()
                event.stop()
                return
            if not self._suggestions:
                return
            self._normalize_suggestion_index()
            self._suggestion_index = (self._suggestion_index + 1) % len(self._suggestions)
            self._sync_suggestion_cursor()
            event.prevent_default()
            event.stop()
            return

        if event.key == "up":
            if history_active:
                self._history_prev(prompt)
                event.prevent_default()
                event.stop()
                return
            if not self._suggestions and self._history_prev(prompt):
                event.prevent_default()
                event.stop()
                return
            if not self._suggestions:
                return
            self._normalize_suggestion_index()
            self._suggestion_index = (self._suggestion_index - 1) % len(self._suggestions)
            self._sync_suggestion_cursor()
            event.prevent_default()
            event.stop()
            return

        if event.key == "tab":
            if not self._suggestions:
                return
            self._apply_suggestion()
            event.prevent_default()
            event.stop()
            return

    def _render_suggestions(self) -> None:
        suggestion_view = self.query_one("#suggestions", Static)

        if not self._suggestions:
            self._hide_suggestions()
            return

        suggestion_view.add_class("visible")
        self._sync_suggestion_cursor()

    def _hide_suggestions(self) -> None:
        suggestion_view = self.query_one("#suggestions", Static)
        suggestion_view.remove_class("visible")
        suggestion_view.update("")

    def _sync_suggestion_cursor(self) -> None:
        suggestion_view = self.query_one("#suggestions", Static)
        self._normalize_suggestion_index()
        if not self._suggestions:
            suggestion_view.update("")
            return

        max_visible = max(1, int(self.SUGGESTION_MAX_VISIBLE))
        total = len(self._suggestions)
        start = 0
        if total > max_visible:
            start = min(max(0, self._suggestion_index - max_visible + 1), total - max_visible)
        end = min(total, start + max_visible)

        lines = []
        for index in range(start, end):
            suggestion = self._suggestions[index]
            prefix = ">" if index == self._suggestion_index else " "
            lines.append(f"{prefix} {suggestion}")
        suggestion_view.update("\n".join(lines))

    def _normalize_suggestion_index(self) -> None:
        if not self._suggestions:
            self._suggestion_index = 0
            return
        if self._suggestion_index < 0 or self._suggestion_index >= len(self._suggestions):
            self._suggestion_index = 0

    def _scroll_suggestion_cursor_into_view(self) -> None:
        if not self._suggestions:
            return

        suggestion_view = self.query_one("#suggestions", Static)
        viewport_height = 1
        try:
            viewport_height = max(1, int(suggestion_view.content_region.height))
        except Exception:
            viewport_height = 1

        try:
            top = int(getattr(suggestion_view, "scroll_y", 0))
        except Exception:
            top = 0

        bottom = top + viewport_height - 1
        target_top = top
        if self._suggestion_index < top:
            target_top = self._suggestion_index
        elif self._suggestion_index > bottom:
            target_top = self._suggestion_index - viewport_height + 1
        else:
            return

        self._scroll_widget_to(suggestion_view, max(0, target_top))

    def _scroll_widget_to(self, widget: Static | RichLog, y: int) -> None:
        try:
            widget.scroll_to(y=y, animate=False)
            return
        except TypeError:
            try:
                widget.scroll_to(y=y)
                return
            except Exception:
                pass
        except Exception:
            pass

        try:
            current = int(getattr(widget, "scroll_y", 0))
        except Exception:
            current = 0
        delta = y - current
        if delta == 0:
            return
        try:
            widget.scroll_relative(y=delta)
        except Exception:
            return

    def _queue_log_scroll_to_absolute_line(self, absolute_line: int) -> None:
        self._pending_log_scroll_absolute_line = max(0, int(absolute_line))

    def _apply_pending_log_scroll(self) -> None:
        if self._pending_log_scroll_absolute_line is None:
            return
        target_line = self._pending_log_scroll_absolute_line
        self._pending_log_scroll_absolute_line = None
        self._scroll_log_to_absolute_line(target_line)

    def _scroll_log_to_absolute_line(self, absolute_line: int) -> None:
        logger = self._query_log()
        if logger is None:
            return
        visible_start = int(getattr(logger, "_start_line", 0))
        relative_y = max(0, int(absolute_line) - visible_start)
        self._scroll_widget_to(logger, relative_y)

    def _apply_suggestion(self) -> None:
        if not self._suggestions:
            return

        selected = self._suggestions[self._suggestion_index]
        prompt = self.query_one("#prompt", Input)
        prompt.value = apply_completion(prompt.value, selected)

        prompt.cursor_position = len(prompt.value)
        self._hide_suggestions()
        self._suggestions = []

    def _push_prompt_history(self, line: str) -> None:
        value = line.strip()
        if not value:
            return
        if self._prompt_history and self._prompt_history[-1] == value:
            return
        self._prompt_history.append(value)
        if len(self._prompt_history) > 200:
            self._prompt_history = self._prompt_history[-200:]
        self._prompt_history_index = None
        self._prompt_history_draft = ""

    def _history_prev(self, prompt: Input) -> bool:
        if not self._prompt_history:
            return False
        if self._prompt_history_index is None:
            self._prompt_history_draft = prompt.value
            self._prompt_history_index = len(self._prompt_history) - 1
        elif self._prompt_history_index > 0:
            self._prompt_history_index -= 1
        self._set_prompt_value(self._prompt_history[self._prompt_history_index])
        return True

    def _history_next(self, prompt: Input) -> bool:
        if self._prompt_history_index is None:
            return False
        if self._prompt_history_index < len(self._prompt_history) - 1:
            self._prompt_history_index += 1
            self._set_prompt_value(self._prompt_history[self._prompt_history_index])
            return True
        self._prompt_history_index = None
        self._set_prompt_value(self._prompt_history_draft)
        return True

    def _set_prompt_value(self, value: str) -> None:
        self._suppress_suggestions_once = True
        self._suggestions = []
        self._hide_suggestions()
        prompt = self.query_one("#prompt", Input)
        prompt.value = value
        prompt.cursor_position = len(prompt.value)

    def _register_log_block(
        self,
        content: str,
        start_line: int,
        end_line: int,
        visible_start_line: int,
    ) -> None:
        if end_line <= start_line:
            return
        self._log_blocks.append((start_line, end_line, content))
        self._log_blocks = [
            block for block in self._log_blocks if block[1] > visible_start_line
        ]

    def _find_log_block(self, absolute_line: int) -> str | None:
        for start_line, end_line, content in reversed(self._log_blocks):
            if start_line <= absolute_line < end_line:
                return content
        if self._log_blocks:
            return self._log_blocks[-1][2]
        return None

    def _copy_block_to_clipboard(self, content: str, success_message: str) -> None:
        system_copied = self._copy_to_system_clipboard(content)
        tkinter_copied = False
        if not system_copied:
            tkinter_copied = self._copy_to_tkinter_clipboard(content)
        if system_copied or tkinter_copied:
            self.notify(success_message, severity="information")
            return

        try:
            self.copy_to_clipboard(content)
        except Exception:
            self.notify("Clipboard copy failed.", severity="error")
            return

        if self._driver is None:
            self.notify(
                "Clipboard backend unavailable. Install wl-copy/xclip/xsel for reliable paste.",
                severity="error",
            )
            return
        self.notify(
            "Copy requested via terminal. If Ctrl+V fails, install wl-copy/xclip/xsel.",
            severity="warning",
        )

    def _copy_to_system_clipboard(self, content: str) -> bool:
        commands: list[list[str]] = []
        if os.name == "nt":
            commands.extend([["clip"], ["clip.exe"]])
        elif sys.platform == "darwin":
            commands.append(["pbcopy"])
        else:
            if os.getenv("WAYLAND_DISPLAY"):
                commands.append(["wl-copy"])
            commands.extend(
                [
                    ["xclip", "-selection", "clipboard"],
                    ["xsel", "--clipboard", "--input"],
                    ["wl-copy"],
                    ["pbcopy"],
                    ["clip.exe"],
                ]
            )

        checked: set[tuple[str, ...]] = set()
        for command in commands:
            key = tuple(command)
            if key in checked:
                continue
            checked.add(key)
            if shutil.which(command[0]) is None:
                continue
            try:
                subprocess.run(
                    command,
                    input=content,
                    text=True,
                    check=True,
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                )
                return True
            except Exception:
                continue
        return False

    def _copy_to_tkinter_clipboard(self, content: str) -> bool:
        try:
            import tkinter as tk

            root = tk.Tk()
            root.withdraw()
            root.clipboard_clear()
            root.clipboard_append(content)
            root.update_idletasks()
            root.update()
            root.destroy()
            return True
        except Exception:
            return False

    def refresh_table(self) -> None:
        table = self.query_one("#ranking", DataTable)
        table.clear(columns=False)
        sorted_results = self._filtered_sorted_results()

        for rank, item in enumerate(sorted_results, start=1):
            table.add_row(
                str(rank),
                item.ticker,
                item.company_name or "N/A",
                f"{item.score:.2f}",
                self._score_badge(item.score),
                format_metric(item.price),
                item.price_time or "N/A",
                format_metric(item.intrinsic_value),
                format_metric(item.mos, percentage=True),
                format_metric(item.pe),
                format_metric(item.pb),
                format_metric(item.dividend_rate),
                key=item.ticker,
            )

        if sorted_results:
            first = sorted_results[0]
            self._show_details(first)
        else:
            self._details_text = self.tr("No row selected.")
            self.query_one("#details", Static).update(self._details_text)
        table.refresh(repaint=True, layout=True)
        self.refresh(repaint=True, layout=True)

    def _filtered_sorted_results(self) -> list[StockAnalysis]:
        sorted_results = self._sorted_results()
        if not self._ticker_search_query:
            return sorted_results
        query = self._ticker_search_query.strip().upper()
        if not query:
            return sorted_results
        return [
            item
            for item in sorted_results
            if matches_ticker_or_company_query(item.ticker, item.company_name, query)
        ]

    def _extract_header_column_index(self, event: DataTable.HeaderSelected) -> int | None:
        index = getattr(event, "column_index", None)
        if isinstance(index, int):
            return index

        coordinate = getattr(event, "coordinate", None)
        if coordinate is not None:
            coord_index = getattr(coordinate, "column", None)
            if isinstance(coord_index, int):
                return coord_index
        return None

    def _column_id_for_index(self, index: int) -> str | None:
        if 0 <= index < len(self.SORTABLE_COLUMNS):
            return self.SORTABLE_COLUMNS[index]
        return None

    def available_sort_columns(self) -> list[str]:
        return list(self.SORTABLE_COLUMNS)

    def get_sort_state(self) -> tuple[str, str]:
        direction = "desc" if self._sort_reverse else "asc"
        return self._sort_column, direction

    def set_sort(self, column: str, direction: str | None = None) -> tuple[bool, str]:
        normalized = self._normalize_sort_column(column)
        if normalized is None:
            choices = ", ".join(self.SORTABLE_COLUMNS)
            return False, f"Unknown sort column: {column}. Use one of: {choices}"

        direction_value = (direction or "").strip().lower()
        if direction_value and direction_value not in {"asc", "desc"}:
            return False, "Sort direction must be 'asc' or 'desc'."

        if direction_value:
            reverse = direction_value == "desc"
        elif self._sort_column == normalized:
            reverse = not self._sort_reverse
        else:
            reverse = False

        self._sort_column = normalized
        self._sort_reverse = reverse
        self.refresh_table()
        final_direction = "desc" if reverse else "asc"
        return True, f"Sorted by {normalized} ({final_direction})."

    def _normalize_sort_column(self, column: str) -> str | None:
        value = column.strip().lower()
        if not value:
            return None
        cleaned = value.replace("-", "_").replace(" ", "_").replace("/", "")
        canonical = self.SORT_COLUMN_ALIASES.get(cleaned, cleaned)
        if canonical in self.SORTABLE_COLUMNS:
            return canonical
        return None

    def _sorted_results(self) -> list[StockAnalysis]:
        results = list(self._current_results)
        if not results:
            return results

        if self._sort_column == "rank":
            if self._sort_reverse:
                results.reverse()
            return results

        present: list[StockAnalysis] = []
        missing: list[StockAnalysis] = []
        for item in results:
            value = self._sort_value(item, self._sort_column)
            if value is None or value == "":
                missing.append(item)
            else:
                present.append(item)

        present.sort(key=lambda item: self._sort_value(item, self._sort_column), reverse=self._sort_reverse)
        return present + missing

    def _sort_value(self, item: StockAnalysis, column: str) -> str | float | None:
        if column == "ticker":
            return item.ticker
        if column == "company":
            return (item.company_name or "").lower()
        if column in {"score", "rating"}:
            return item.score
        if column == "price":
            return item.price
        if column == "as_of":
            return item.price_time or ""
        if column == "v":
            return item.intrinsic_value
        if column == "mos":
            return item.mos
        if column == "pe":
            return item.pe
        if column == "pb":
            return item.pb
        if column == "dividend":
            return item.dividend_rate
        return None

    def _show_details(self, analysis: StockAnalysis) -> None:
        self.selected_ticker = analysis.ticker
        details = self.query_one("#details", Static)

        lines = [
            f"{self.tr('Ticker')}: {analysis.ticker}",
            f"{self.tr('Company')}: {analysis.company_name or 'N/A'}",
            f"{self.tr('Score')}: {analysis.score:.2f}",
            f"{self.tr('Price')}: {format_metric(analysis.price)}",
            f"{self.tr('As of')}: {analysis.price_time or 'N/A'}",
            f"V: {format_metric(analysis.intrinsic_value)}",
            f"MoS: {format_metric(analysis.mos, percentage=True)}",
            "",
            self.tr("Criteria:"),
        ]
        for criterion in analysis.criteria:
            lines.append(f"{criterion.index}. {self.tr(criterion.label)}: {criterion.status}")
            lines.append(f"   {self.tr('Note')}: {self.tr(criterion.note)}")

        if analysis.notes:
            lines.append("")
            lines.append(self.tr("Notes:"))
            lines.extend(f"- {self.tr(note)}" for note in analysis.notes)

        self._details_text = "\n".join(lines)
        details.update(self._details_text)

    def _schedule_price_refresh(self) -> None:
        asyncio.create_task(self._refresh_prices())

    async def _refresh_prices(self) -> None:
        if self._refreshing or not self.engine.analyses:
            return
        self._refreshing = True
        try:
            ranked = await asyncio.to_thread(self.engine.refresh_prices)
            self._current_results = filter_ranked(ranked, top=self.scan_top, min_score=self.scan_min_score)
            self.refresh_table()
        except Exception as exc:
            self.write_log(f"Price refresh error: {exc}")
        finally:
            self._refreshing = False

    def _save_settings(self) -> tuple[bool, str | None]:
        try:
            save_user_settings(self.settings)
        except Exception as exc:
            return False, str(exc)
        return True, None

    def _set_scan_loading(self, loading: bool) -> None:
        self._scan_loading = loading
        logger = self._query_log()
        if not loading:
            if self._scan_spinner_timer is not None:
                try:
                    self._scan_spinner_timer.stop()
                except Exception:
                    pass
                self._scan_spinner_timer = None
            if logger is not None:
                logger.border_title = ""
            return

        if logger is None:
            return

        self._scan_spinner_step = 0
        if self._scan_spinner_timer is None:
            self._scan_spinner_timer = self.set_interval(0.12, self._tick_scan_spinner)
        self._tick_scan_spinner()

    def _tick_scan_spinner(self) -> None:
        if not self._scan_loading:
            return
        logger = self._query_log()
        if logger is None:
            return
        spinner = self._scan_spinner_frames[self._scan_spinner_step % len(self._scan_spinner_frames)]
        self._scan_spinner_step += 1
        logger.border_title = f"{spinner} {self.tr('Loading tickers...')}"

    def _reset_timer(self) -> None:
        if self._timer is not None:
            try:
                self._timer.stop()
            except Exception:
                pass
        self._timer = self.set_interval(self.refresh_seconds, self._schedule_price_refresh)

    def _setup_table_columns(self) -> None:
        table = self.query_one("#ranking", DataTable)
        table.cursor_type = "row"
        table.clear(columns=True)
        table.add_columns(
            self.tr("rank"),
            self.tr("ticker"),
            self.tr("company"),
            self.tr("score"),
            self.tr("rating"),
            self.tr("price"),
            self.tr("as_of"),
            "V",
            "MoS",
            "P/E",
            "P/B",
            self.tr("dividend"),
        )

    def _score_badge(self, score: float) -> str:
        if score >= self.score_green_min:
            return "🟢"
        if score >= self.score_orange_min:
            return "🟠"
        return "🔴"

    def action_noop(self) -> None:
        return

    def action_focus_details(self) -> None:
        self._details_nav_mode = True
        self.query_one("#details", DetailsPanel).focus()
        self.write_log("Details navigation enabled (Esc or Ctrl+P to return to prompt).")

    def action_focus_prompt(self) -> None:
        self._details_nav_mode = False
        self.query_one("#prompt", Input).focus()

    def action_clear_outputs(self) -> None:
        logger = self.query_one("#log", RichLog)
        logger.clear()
        self._log_blocks = []
        if not self._scan_loading:
            logger.border_title = ""
        self.notify(self.tr("Output panel cleared."), severity="information")

    def action_show_shortcuts(self) -> None:
        self.write_log(self.processor.shortcuts_text(), translate=False)
        self.notify(self.tr("Shortcuts written to output."), severity="information")

    def action_search_tickers(self) -> None:
        if self._ticker_search_mode:
            self._stop_ticker_search(keep_filter=False)
            self.notify(self.tr("Ticker search cleared."), severity="information")
            return
        self._start_ticker_search()

    def _start_ticker_search(self) -> None:
        prompt = self.query_one("#prompt", Input)
        self._ticker_search_mode = True
        self._ticker_search_draft = prompt.value
        self._set_prompt_value(self._ticker_search_query)
        prompt.placeholder = self.tr("Search tickers (Enter to apply, Esc to cancel)")
        prompt.focus()
        self.notify(self.tr("Ticker search mode enabled."), severity="information")

    def _stop_ticker_search(self, keep_filter: bool) -> None:
        prompt = self.query_one("#prompt", Input)
        self._ticker_search_mode = False
        if not keep_filter:
            self._ticker_search_query = ""
            self.refresh_table()
        self._set_prompt_value(self._ticker_search_draft)
        prompt.placeholder = self.tr("Type /help")

    def _scroll_details(self, key: str) -> None:
        details = self.query_one("#details", Static)
        if key == "up":
            self._try_scroll(details, ("scroll_up", "action_scroll_up"), y=-1)
            return
        if key == "down":
            self._try_scroll(details, ("scroll_down", "action_scroll_down"), y=1)
            return
        if key == "pageup":
            self._try_scroll(details, ("scroll_page_up", "action_page_up"), y=-6)
            return
        if key == "pagedown":
            self._try_scroll(details, ("scroll_page_down", "action_page_down"), y=6)
            return
        if key == "home":
            self._try_scroll(details, ("scroll_home", "action_scroll_home"), y=0, home=True)
            return
        if key == "end":
            self._try_scroll(details, ("scroll_end", "action_scroll_end"), y=10**9, end=True)
            return

    def _try_scroll(
        self,
        widget: Static,
        method_names: tuple[str, ...],
        *,
        y: int,
        home: bool = False,
        end: bool = False,
    ) -> None:
        for method_name in method_names:
            method = getattr(widget, method_name, None)
            if not callable(method):
                continue
            try:
                if method_name.startswith("scroll_") and method_name not in {"scroll_up", "scroll_down"}:
                    method(animate=False)
                else:
                    method()
                return
            except TypeError:
                try:
                    method()
                    return
                except Exception:
                    continue
            except Exception:
                continue

        # Fallback for older/newer Textual APIs.
        try:
            if home:
                widget.scroll_to(y=0, animate=False)
                return
            if end:
                widget.scroll_to(y=10**9, animate=False)
                return
            widget.scroll_relative(y=y)
        except Exception:
            return

    def _apply_responsive_layout(self) -> None:
        narrow = self.size.width < 120
        try:
            center = self.query_one("#center", Horizontal)
            ranking = self.query_one("#ranking", DataTable)
            details = self.query_one("#details", Static)
            log = self.query_one("#log", RichLog)
        except Exception:
            return

        for widget in (center, ranking, details, log):
            if narrow:
                widget.add_class("narrow")
            else:
                widget.remove_class("narrow")


def run_tui() -> None:
    app = GrahamApp()
    app.run()
