"""textual dashboard for absolutelyright.

four screens, tabs within screens, no walls of text. styled like an old
handheld game — dithered bars, scanlines, one loud accent color — but every
number on screen is a true, uncurated observation from local transcripts.
"""

from __future__ import annotations

from datetime import datetime
from pathlib import Path
from typing import Any, ClassVar

from rich.text import Text
from textual import work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.timer import Timer
from textual.widgets import (
    ContentSwitcher,
    DataTable,
    Digits,
    Footer,
    Label,
    ListItem,
    ListView,
    Sparkline,
    Static,
    TabbedContent,
    TabPane,
    Tree,
)
from textual_plotext import PlotextPlot
from watchdog.events import FileSystemEvent, FileSystemEventHandler
from watchdog.observers import Observer
from watchdog.observers.api import BaseObserver

from absolutelyright import _stats
from absolutelyright._records import DEFAULT_CLAUDE_DIR, Session, load_sessions

SECTIONS = ("overview", "sessions", "language", "rhythm")

BANNER = """\
   ▌        ▜    ▐     ▜        ▗    ▌  ▐  ▐
▝▀▖▛▀▖▞▀▘▞▀▖▐ ▌ ▌▜▀ ▞▀▖▐ ▌ ▌ ▙▀▖▄ ▞▀▌▛▀▖▜▀ ▐
▞▀▌▌ ▌▝▀▖▌ ▌▐ ▌ ▌▐ ▖▛▀ ▐ ▚▄▌ ▌  ▐ ▚▄▌▌ ▌▐ ▖▝
▝▀▘▀▀ ▀▀ ▝▀  ▘▝▀▘ ▀ ▝▀▘ ▘▗▄▘ ▘  ▀▘▗▄▘▘ ▘ ▀ ▝"""

BG = "#0d0807"
PANEL = "#171009"
SCAN = "#1d130b"
INK = "#ffd9b8"
MUTED = "#9c6f52"
BRIGHT = "#ff7a45"
TRACK = "#33200f"
BORDER = "#5c3322"
BRIGHT_RGB = (255, 122, 69)


def _compact(n: int | float) -> str:
    for threshold, suffix in ((1_000_000_000, "b"), (1_000_000, "m"), (1_000, "k")):
        if n >= threshold:
            return f"{n / threshold:.1f}{suffix}"
    return str(round(n))


def _bar(count: int, peak: int, width: int = 28) -> Text:
    filled = round(width * count / peak) if peak else 0
    return Text.assemble(("▓" * filled, BRIGHT), ("░" * (width - filled), TRACK))


class _TranscriptChanged(FileSystemEventHandler):
    """bridge watchdog's thread into the app: any transcript write → reload."""

    def __init__(self, app: AbsolutelyApp) -> None:
        self.app = app

    def on_any_event(self, event: FileSystemEvent) -> None:
        if event.is_directory or not str(event.src_path).endswith(".jsonl"):
            return
        self.app.call_from_thread(self.app._schedule_reload)


def _active(minutes: int) -> str:
    if minutes >= 60:
        return f"{minutes // 60}h{minutes % 60:02d}m"
    return f"{minutes}m"


class AbsolutelyApp(App[None]):
    """analytics over your claude code sessions, one screen at a time."""

    TITLE = "absolutely right!"

    CSS = f"""
    Screen {{
        background: {BG};
        color: {INK};
    }}

    * {{
        scrollbar-color: {BORDER};
        scrollbar-color-hover: {BRIGHT};
        scrollbar-color-active: {BRIGHT};
        scrollbar-background: {PANEL};
        scrollbar-corner-color: {PANEL};
        scrollbar-size-vertical: 1;
    }}

    #hero {{
        height: 6;
        padding: 0 2;
        background: {PANEL};
        border-bottom: double {BORDER};
    }}

    #banner {{
        color: {BRIGHT};
    }}

    #stats {{
        color: {MUTED};
        height: 1;
    }}

    #main {{
        height: 1fr;
    }}

    #sidebar {{
        width: 16;
        background: {PANEL};
        border-right: heavy {BORDER};
    }}

    #sidebar ListView {{
        height: 1fr;
        padding: 1 0;
        background: {PANEL};
    }}

    #sidebar ListItem {{
        padding: 0 2;
        background: {PANEL};
        color: {MUTED};
    }}

    #sidebar ListItem.-highlight {{
        background: {BRIGHT};
        color: {BG};
        text-style: bold;
    }}

    #detail {{
        width: 1fr;
        padding: 1 2;
    }}

    TabbedContent {{
        height: 1fr;
    }}

    TabPane {{
        height: 1fr;
        padding: 1 0 0 0;
    }}

    Tab {{
        color: {MUTED};
    }}

    Tab.-active {{
        color: {BRIGHT};
        text-style: bold;
    }}

    Underline > .underline--bar {{
        color: {BRIGHT};
        background: {TRACK};
    }}

    DataTable {{
        height: 1fr;
        background: {BG};
    }}

    DataTable > .datatable--header {{
        background: {BG};
        color: {BRIGHT};
        text-style: bold;
    }}

    DataTable > .datatable--cursor {{
        background: {TRACK};
    }}

    DataTable > .datatable--even-row {{
        background: {SCAN};
    }}

    DataTable > .datatable--odd-row {{
        background: {BG};
    }}

    .caption {{
        color: {MUTED};
        padding: 0 0 1 0;
    }}

    .headline {{
        color: {BRIGHT};
        text-style: bold;
        padding: 0 0 1 0;
    }}

    .noise {{
        color: {TRACK};
        height: 1;
        overflow: hidden;
    }}

    #digits-row {{
        height: 6;
    }}

    #tokens-row {{
        height: 5;
        margin: 1 0 0 0;
    }}

    #model-mix {{
        padding: 1 0;
    }}

    #bottom-split {{
        height: 1fr;
    }}

    .stat {{
        width: 1fr;
        height: 6;
    }}

    .stat Digits {{
        color: {BRIGHT};
        width: auto;
    }}

    .stat Label {{
        color: {MUTED};
    }}

    Tree {{
        background: {BG};
        color: {INK};
    }}

    Tree > .tree--guides {{
        color: {BORDER};
    }}

    Tree > .tree--guides-hover, Tree > .tree--guides-selected {{
        color: {BRIGHT};
    }}

    Tree > .tree--cursor {{
        background: {TRACK};
    }}

    Sparkline {{
        height: 3;
        margin: 0 0 1 0;
    }}

    Sparkline > .sparkline--max-color {{
        color: {BRIGHT};
    }}

    Sparkline > .sparkline--min-color {{
        color: {TRACK};
    }}

    .split {{
        width: 1fr;
        padding: 0 2 0 0;
    }}

    PlotextPlot {{
        height: 1fr;
    }}

    Footer {{
        background: {PANEL};
    }}

    FooterKey {{
        background: {PANEL};
        color: {MUTED};
    }}

    FooterKey > .footer-key--key {{
        background: {PANEL};
        color: {BRIGHT};
    }}

    FooterKey > .footer-key--description {{
        background: {PANEL};
        color: {MUTED};
    }}
    """

    BINDINGS: ClassVar = [
        Binding("q", "quit", "quit"),
        Binding("7", "window(7)", "past week"),
        Binding("3", "window(30)", "past month"),
        Binding("a", "window(None)", "everything"),
    ]

    def __init__(
        self, claude_dir: Path = DEFAULT_CLAUDE_DIR, days: int | None = None
    ) -> None:
        super().__init__()
        self.claude_dir = claude_dir
        self.days = days
        self.sessions: list[Session] = []
        self._observer: BaseObserver | None = None
        self._reload_timer: Timer | None = None

    def _window_label(self) -> str:
        if self.days:
            return f"LAST {self.days} DAYS"
        return "EVERYTHING CLAUDE CODE KEPT"

    def compose(self) -> ComposeResult:
        with Vertical(id="hero"):
            yield Static(BANNER, id="banner")
            yield Static("LOADING TRANSCRIPTS…", id="stats")
        with Horizontal(id="main"):
            with Vertical(id="sidebar"):
                yield ListView(
                    *[ListItem(Label(name), id=f"nav-{name}") for name in SECTIONS],
                    id="nav",
                )
            with ContentSwitcher(initial="overview", id="detail"):
                with Vertical(id="overview"):
                    with Horizontal(id="digits-row"):
                        for stat, label in (
                            ("prompts", "PROMPTS TYPED"),
                            ("interrupts", "INTERRUPTS"),
                            ("prs", "PRS OPENED"),
                            ("hours-active", "HOURS ACTIVE"),
                        ):
                            with Vertical(classes="stat", id=f"stat-{stat}"):
                                yield Digits("0", id=f"digits-{stat}")
                                yield Label(label)
                    yield Static("░" * 400, classes="noise")
                    yield Static("", id="spark-caption", classes="caption")
                    yield Sparkline([0], summary_function=max, id="spark")
                    with Horizontal(id="tokens-row"):
                        for stat, label in (
                            ("output-tokens", "OUTPUT TOKENS"),
                            ("cache-read", "CACHE READ TOKENS"),
                        ):
                            with Vertical(classes="stat", id=f"stat-{stat}"):
                                yield Digits("0", id=f"digits-{stat}")
                                yield Label(label)
                    yield Static("", id="model-mix")
                    yield Static("░" * 400, classes="noise")
                    with Horizontal(id="bottom-split"):
                        with Vertical(classes="split"):
                            yield Static("TOP PROJECTS", classes="headline")
                            yield DataTable(id="top-projects", cursor_type="none")
                        with Vertical(classes="split"):
                            yield Static("BY PARENT FOLDER", classes="headline")
                            yield DataTable(id="top-parents", cursor_type="none")
                with Vertical(id="sessions"):
                    yield Static(
                        "EVERY SESSION ON THIS MACHINE · ACTIVE = TIME BETWEEN EVENTS, IDLE-CAPPED AT 5 MIN",
                        classes="caption",
                    )
                    yield DataTable(
                        id="sessions-table", cursor_type="row", zebra_stripes=True
                    )
                with TabbedContent(id="language"):
                    with TabPane("phrases", id="tab-phrases"):
                        yield Static("", id="namesake", classes="headline")
                        with Horizontal():
                            with Vertical(classes="split"):
                                yield Static(
                                    "YOURS · EXPAND A WORD TO FOLLOW THE SENTENCE · CONTINUATIONS SEEN 2+ TIMES",
                                    classes="caption",
                                )
                                yield Tree("·", id="phrase-tree-you")
                            with Vertical(classes="split"):
                                yield Static("CLAUDE'S", classes="caption")
                                yield Tree("·", id="phrase-tree-claude")
                    with (
                        TabPane("words", id="tab-words"),
                        TabbedContent(initial="words-distinctive"),
                    ):
                        with (
                            TabPane("distinctive", id="words-distinctive"),
                            Horizontal(),
                        ):
                            with Vertical(classes="split"):
                                yield Static(
                                    "YOURS · USED AT A HIGHER RATE THAN CLAUDE — THE OTHER CORPUS IS THE BASELINE, NO STOP WORDS",
                                    classes="caption",
                                )
                                yield DataTable(id="dist-you", cursor_type="none")
                            with Vertical(classes="split"):
                                yield Static("CLAUDE'S", classes="caption")
                                yield DataTable(id="dist-claude", cursor_type="none")
                        with TabPane("raw", id="words-raw"), Horizontal():
                            with Vertical(classes="split"):
                                yield Static(
                                    "YOU SAID · RAW COUNTS, NOTHING FILTERED",
                                    classes="caption",
                                )
                                yield DataTable(id="words-you", cursor_type="none")
                            with Vertical(classes="split"):
                                yield Static("CLAUDE SAID", classes="caption")
                                yield DataTable(id="words-claude", cursor_type="none")
                    with TabPane("on repeat", id="tab-repeat"):
                        yield Static(
                            "PROMPTS YOU'VE TYPED WORD-FOR-WORD MORE THAN ONCE",
                            classes="caption",
                        )
                        yield DataTable(id="repeat", cursor_type="none")
                    with TabPane("corrections", id="tab-corrections"):
                        yield Static("", id="corrections-rate", classes="caption")
                        yield DataTable(
                            id="corrections-table",
                            cursor_type="row",
                            zebra_stripes=True,
                        )
                with TabbedContent(id="rhythm"):
                    with TabPane("hours", id="tab-hours"):
                        yield PlotextPlot(id="hours-plot")
                    with TabPane("days", id="tab-days"):
                        yield PlotextPlot(id="days-plot")
                    with TabPane("projects", id="tab-projects"):
                        yield DataTable(id="projects-table", cursor_type="none")
                    with TabPane("tools", id="tab-tools"):
                        yield DataTable(id="tools-table", cursor_type="none")
        yield Footer()

    def on_mount(self) -> None:
        self.query_one("#top-projects", DataTable).add_columns("project", "prompts", "")
        self.query_one("#top-parents", DataTable).add_columns("parent", "prompts", "")
        self.query_one("#sessions-table", DataTable).add_columns(
            "when", "title", "project", "active", "prompts", "stops"
        )
        self.query_one("#words-you", DataTable).add_columns("word", "count", "")
        self.query_one("#words-claude", DataTable).add_columns("word", "count", "")
        self.query_one("#dist-you", DataTable).add_columns(
            "word", "count", "vs them", ""
        )
        self.query_one("#dist-claude", DataTable).add_columns(
            "word", "count", "vs them", ""
        )
        for tree_id in ("phrase-tree-you", "phrase-tree-claude"):
            tree = self.query_one(f"#{tree_id}", Tree)
            tree.show_root = False
            tree.guide_depth = 3
        self.query_one("#repeat", DataTable).add_columns("count", "", "prompt")
        self.query_one("#corrections-table", DataTable).add_columns("project", "prompt")
        self.query_one("#projects-table", DataTable).add_columns(
            "project", "prompts", "", "stops", "output tokens"
        )
        self.query_one("#tools-table", DataTable).add_columns("tool", "count", "")
        self._load()
        self._start_watching()

    def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
        if event.item is not None and event.item.id is not None:
            self.query_one(
                "#detail", ContentSwitcher
            ).current = event.item.id.removeprefix("nav-")

    def action_window(self, days: int | None) -> None:
        self.days = days
        self.query_one("#stats", Static).update("RELOADING…")
        self._load()

    def _start_watching(self) -> None:
        projects = self.claude_dir / "projects"
        if not projects.is_dir():
            return
        handler = _TranscriptChanged(self)
        self._observer = Observer()
        self._observer.schedule(handler, str(projects), recursive=True)
        self._observer.daemon = True
        self._observer.start()

    def _schedule_reload(self) -> None:
        """debounce bursts of fs events into one reload."""
        if self._reload_timer is not None:
            self._reload_timer.stop()
        self._reload_timer = self.set_timer(1.0, self._load)

    def on_unmount(self) -> None:
        if self._observer is not None:
            self._observer.stop()

    @work(thread=True, exclusive=True)
    def _load(self) -> None:
        sessions = load_sessions(self.claude_dir, days=self.days)
        self.call_from_thread(self._render, sessions)
        maps = {who: _stats.phrase_children(sessions, who) for who in ("you", "claude")}
        self.call_from_thread(self._render_trees, maps)

    def _render_trees(self, maps: dict[str, dict]) -> None:
        self._phrase_maps = maps
        for who in ("you", "claude"):
            tree = self.query_one(f"#phrase-tree-{who}", Tree)
            tree.clear()
            roots = maps[who].get((), [])[:40]
            peak = roots[0][1] if roots else 0
            for word, count in roots:
                prefix = (word,)
                node = tree.root.add(
                    self._tree_label(word, count, peak),
                    data=prefix,
                )
                if prefix not in maps[who]:
                    node.allow_expand = False

    @staticmethod
    def _tree_label(word: str, count: int, peak: int) -> Text:
        return Text.assemble(
            (word, f"bold {INK}"),
            ("  ", ""),
            (str(count), MUTED),
            ("  ", ""),
            ("▓" * max(1, round(8 * count / peak)) if peak else "", BRIGHT),
        )

    def on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None:
        node = event.node
        prefix = node.data
        if prefix is None or node.children or event.control.id is None:
            return
        who = event.control.id.removeprefix("phrase-tree-")
        maps = getattr(self, "_phrase_maps", {}).get(who, {})
        children = maps.get(prefix, [])[:12]
        peak = children[0][1] if children else 0
        for word, count in children:
            child_prefix = (*prefix, word)
            child = node.add(self._tree_label(word, count, peak), data=child_prefix)
            if child_prefix not in maps:
                child.allow_expand = False

    def _fill_bars(self, table_id: str, rows: list[tuple[str, int]]) -> None:
        table = self.query_one(f"#{table_id}", DataTable)
        table.clear()
        peak = max((n for _, n in rows), default=0)
        for label, count in rows:
            table.add_row(label, Text(str(count), justify="right"), _bar(count, peak))

    def _render(self, sessions: list[Session]) -> None:
        self.sessions = sessions
        stats = _stats.overview(sessions)

        span = ""
        if stats["first"]:
            first = datetime.fromisoformat(stats["first"]).strftime("%b %d")
            last = datetime.fromisoformat(stats["last"]).strftime("%b %d")
            span = f"{first} → {last} · ".upper()
        live = " · ▮ LIVE" if self._observer is not None else ""
        self.query_one("#stats", Static).update(
            f"ON THIS MACHINE ({self.claude_dir}) · {span}{self._window_label()}{live}"
        )

        self._render_overview(sessions, stats)

        table = self.query_one("#sessions-table", DataTable)
        table.clear()
        for row in _stats.session_rows(sessions):
            table.add_row(
                Text(row["when"], style=MUTED),
                Text(row["title"][:70], style=INK),
                Text(row["project"], style=MUTED),
                Text(_active(row["activeMinutes"]), justify="right"),
                Text(str(row["prompts"]), justify="right"),
                Text(str(row["interrupts"]), justify="right", style=BRIGHT),
            )

        self._fill_bars("words-you", _stats.word_counts(sessions, "you", 40))
        self._fill_bars("words-claude", _stats.word_counts(sessions, "claude", 40))
        for who in ("you", "claude"):
            table = self.query_one(f"#dist-{who}", DataTable)
            table.clear()
            rows = _stats.distinctive_words(sessions, who)
            peak = rows[0][2] if rows else 0
            for word, count, ratio in rows:
                table.add_row(
                    Text(word, style=f"bold {INK}"),
                    Text(str(count), justify="right"),
                    Text(f"{ratio:,.0f}x", justify="right", style=MUTED),
                    _bar(round(ratio), round(peak), width=16),
                )
        for who in ("you", "claude"):
            tree = self.query_one(f"#phrase-tree-{who}", Tree)
            if not tree.root.children:
                tree.root.add_leaf("building tree…")

        right = _stats.absolutely_right(sessions)
        self.query_one("#namesake", Static).update(
            f'claude said "you\'re absolutely right" {right} times'
        )

        repeat = self.query_one("#repeat", DataTable)
        repeat.clear()
        repeats = _stats.repeated_prompts(sessions, top=40, min_count=2)
        peak = max((n for _, n in repeats), default=0)
        for text, count in repeats:
            repeat.add_row(
                Text(str(count), justify="right"),
                _bar(count, peak, width=12),
                text.replace("\n", " ")[:110],
            )

        corrections = _stats.corrections(sessions)
        total = len(_stats.all_prompts(sessions))
        rate = 100 * len(corrections) / total if total else 0
        self.query_one("#corrections-rate", Static).update(
            f"{len(corrections)} OF {total} PROMPTS ({rate:.1f}%) OPEN WITH NO / WAIT / STOP / ACTUALLY"
        )
        ctable = self.query_one("#corrections-table", DataTable)
        ctable.clear()
        for p in corrections:
            ctable.add_row(Text(p.project, style=MUTED), p.text.splitlines()[0][:110])

        projects = self.query_one("#projects-table", DataTable)
        projects.clear()
        project_rows = _stats.by_project(sessions)
        peak = max((r["prompts"] for r in project_rows), default=0)
        for row in project_rows:
            projects.add_row(
                row["project"],
                Text(str(row["prompts"]), justify="right"),
                _bar(row["prompts"], peak, width=20),
                Text(str(row["interrupts"]), justify="right"),
                Text(f"{row['outputTokens']:,}", justify="right", style=MUTED),
            )

        self._fill_bars("tools-table", _stats.tool_counts(sessions)[:25])

        self._render_plots(sessions)

    def _render_overview(self, sessions: list[Session], stats: dict[str, Any]) -> None:
        hours_active = sum(s.active_seconds for s in sessions) / 3600
        self.query_one("#digits-prompts", Digits).update(_compact(stats["prompts"]))
        self.query_one("#digits-interrupts", Digits).update(
            _compact(stats["interrupts"])
        )
        self.query_one("#digits-prs", Digits).update(_compact(stats["prsShipped"]))
        self.query_one("#digits-hours-active", Digits).update(f"{hours_active:.0f}")

        days = min(self.days or 30, 30)
        self.query_one("#spark-caption", Static).update(
            f"PROMPTS PER DAY · LAST {days} DAYS"
        )
        self.query_one("#spark", Sparkline).data = _stats.daily_counts(
            sessions, days=days
        )

        spaced = lambda n: f"{n:,}".replace(",", " ")  # noqa: E731 — Digits has no comma glyph
        self.query_one("#digits-output-tokens", Digits).update(
            spaced(stats["outputTokens"])
        )
        self.query_one("#digits-cache-read", Digits).update(
            spaced(stats["cacheReadTokens"])
        )

        mix = _stats.model_mix(sessions)
        total_turns = sum(n for _, n in mix) or 1
        parts = []
        for name, n in mix[:4]:
            pct = 100 * n / total_turns
            filled = round(pct / 10)
            parts.append(
                f"[bold {INK}]{name.removeprefix('claude-')}[/]"
                f" [{BRIGHT}]{'▓' * filled}[/][{TRACK}]{'░' * (10 - filled)}[/]"
                f" [{MUTED}]{pct:.0f}%[/]"
            )
        self.query_one("#model-mix", Static).update(
            f"[{MUTED}]MODELS[/]   " + "    ".join(parts)
        )

        top = self.query_one("#top-projects", DataTable)
        top.clear()
        rows = _stats.by_project(sessions)[:8]
        peak = max((r["prompts"] for r in rows), default=0)
        for row in rows:
            top.add_row(
                row["project"],
                Text(str(row["prompts"]), justify="right"),
                _bar(row["prompts"], peak, width=24),
            )

        parents = self.query_one("#top-parents", DataTable)
        parents.clear()
        parent_rows = _stats.by_parent(sessions)[:8]
        peak = max((n for _, n in parent_rows), default=0)
        for parent, count in parent_rows:
            parents.add_row(
                parent,
                Text(str(count), justify="right"),
                _bar(count, peak, width=24),
            )

    def _render_plots(self, sessions: list[Session]) -> None:
        histogram = _stats.hour_histogram(sessions)
        plot = self.query_one("#hours-plot", PlotextPlot)
        plt = plot.plt
        plt.clear_data()
        hours = list(range(24))
        plt.bar(
            [f"{h:02d}" for h in hours],
            [histogram.get(h, 0) for h in hours],
            color=BRIGHT_RGB,
            marker="▓",
        )
        plt.title("prompts by local hour")
        plot.refresh()

        weekdays = _stats.weekday_histogram(sessions)
        plot = self.query_one("#days-plot", PlotextPlot)
        plt = plot.plt
        plt.clear_data()
        plt.bar(
            [d for d, _ in weekdays],
            [n for _, n in weekdays],
            color=BRIGHT_RGB,
            marker="▓",
        )
        plt.title("prompts by weekday")
        plot.refresh()


def run(claude_dir: Path = DEFAULT_CLAUDE_DIR, days: int | None = None) -> None:
    AbsolutelyApp(claude_dir=claude_dir, days=days).run()
