#!/usr/bin/env python3
"""Standalone curses-based Git manager TUI with status, diff, and graph views."""

import curses
import os
import subprocess
import sys
from pathlib import Path

# ── Nerd Font Icons ─────────────────────────────────────────────────────────
ICON_GIT = "\ue702"
ICON_BRANCH = "\ue725"
ICON_COMMIT = "\uf417"
ICON_DEFAULT_FILE = "\uf15b"

EXT_ICONS = {
    ".py": "\ue73c", ".js": "\ue74e", ".jsx": "\ue74e", ".ts": "\ue628",
    ".tsx": "\ue628", ".json": "\ue60b", ".yaml": "\ue615", ".yml": "\ue615",
    ".toml": "\ue615", ".md": "\ue73e", ".rs": "\ue7a8", ".go": "\ue627",
    ".html": "\ue736", ".css": "\ue749", ".scss": "\ue749", ".sh": "\ue795",
    ".bash": "\ue795", ".zsh": "\ue795", ".png": "\uf1c5", ".jpg": "\uf1c5",
    ".svg": "\uf1c5", ".lock": "\uf023",
}
NAME_ICONS = {
    "Dockerfile": "\uf308", ".gitignore": "\ue702", ".env": "\ue615",
    "Makefile": "\ue615",
}

def file_icon(filename):
    name = os.path.basename(filename)
    if name in NAME_ICONS:
        return NAME_ICONS[name]
    ext = Path(name).suffix.lower()
    return EXT_ICONS.get(ext, ICON_DEFAULT_FILE)

# ── Dracula Pro Colors ──────────────────────────────────────────────────────
C_BG = 236; C_FG = 255; C_PURPLE = 141; C_CYAN = 43; C_GREEN = 84
C_ORANGE = 209; C_RED = 196; C_YELLOW = 220; C_GREY = 240; C_COMMENT = 97
C_LINENO = 240

STATUS_COLOR_NAMES = {"M": "orange", "A": "green", "D": "red", "??": "grey", "R": "cyan"}


def git(*args, cwd=None):
    r = subprocess.run(["git"] + list(args), capture_output=True, text=True, cwd=cwd)
    return r.stdout, r.stderr, r.returncode


class GitManager:
    def __init__(self):
        self.mode = "status"  # status, diff, graph
        self.cursor = 0
        self.scroll = 0
        self.staged = []      # [(status, filename), ...]
        self.unstaged = []
        self.items = []       # combined display list: ("header"|"file", data)
        self.branch = ""
        self.last_commit = ""
        self.message = ""
        self.input_mode = False
        self.input_buf = ""
        self.input_prompt = ""
        self.input_callback = None
        self.confirm_mode = False
        self.confirm_callback = None
        self.show_help = False
        # Diff state
        self.diff_file = ""
        self.diff_staged = False
        self.diff_left = []   # [(lineno, text, kind)]  kind: "ctx","del","empty"
        self.diff_right = []  # [(lineno, text, kind)]  kind: "ctx","add","empty"
        self.diff_hunks = []  # row indices of hunk starts
        self.diff_scroll = 0
        self.diff_adds = 0
        self.diff_dels = 0
        # Graph state
        self.graph_lines = []  # [(graph_chars, hash, subject, date, author, decorations)]
        self.graph_cursor = 0
        self.graph_scroll = 0
        self.graph_expanded = {}  # hash -> [files] or None
        self.graph_items = []     # flattened display list

    def run(self, stdscr):
        curses.curs_set(0)
        curses.start_color()
        curses.use_default_colors()

        # Named color pairs
        self._pairs = {}
        pid = 1

        # Foreground colors on dark bg
        for name, fg in [("fg", C_FG), ("purple", C_PURPLE), ("cyan", C_CYAN),
                         ("green", C_GREEN), ("orange", C_ORANGE), ("red", C_RED),
                         ("yellow", C_YELLOW), ("grey", C_GREY), ("comment", C_COMMENT),
                         ("lineno", C_LINENO)]:
            curses.init_pair(pid, fg, C_BG)
            self._pairs[name] = pid
            pid += 1

        # Diff highlights
        curses.init_pair(pid, C_RED, 52)
        self._pairs["diff_del"] = pid; pid += 1
        curses.init_pair(pid, C_GREEN, 22)
        self._pairs["diff_add"] = pid; pid += 1
        curses.init_pair(pid, C_FG, C_BG)
        self._pairs["default"] = pid; pid += 1
        # Header bar: white on purple
        curses.init_pair(pid, C_FG, C_PURPLE)
        self._pairs["header"] = pid; pid += 1
        # Status/help bar: white on dark grey
        curses.init_pair(pid, C_FG, 238)
        self._pairs["statusbar"] = pid; pid += 1
        # Cursor highlight: white on muted purple
        curses.init_pair(pid, C_FG, 60)
        self._pairs["cursor"] = pid; pid += 1
        # Input bar: yellow on bg
        curses.init_pair(pid, C_YELLOW, C_BG)
        self._pairs["input"] = pid; pid += 1
        # Confirm bar: red on bg
        curses.init_pair(pid, C_RED, C_BG)
        self._pairs["confirm"] = pid; pid += 1

        stdscr.bkgd(" ", curses.color_pair(self._pairs["default"]))
        stdscr.timeout(100)

        # Check git repo
        _, _, rc = git("rev-parse", "--git-dir")
        if rc != 0:
            stdscr.addstr(0, 0, "Not a git repository. Press q to quit.", self.cp("red"))
            stdscr.refresh()
            while stdscr.getch() != ord("q"):
                pass
            return

        self.refresh_status()

        while True:
            stdscr.erase()
            h, w = stdscr.getmaxyx()
            if self.show_help:
                self._draw_help(stdscr, h, w)
            elif self.mode == "status":
                self._draw_status(stdscr, h, w)
            elif self.mode == "diff":
                self._draw_diff(stdscr, h, w)
            elif self.mode == "graph":
                self._draw_graph(stdscr, h, w)
            stdscr.refresh()

            key = stdscr.getch()
            if key == -1:
                continue
            if self.show_help:
                self.show_help = False
                continue
            if self.input_mode:
                self._handle_input(key)
                continue
            if self.confirm_mode:
                self._handle_confirm(key)
                continue
            if key == ord("?"):
                self.show_help = True
                continue
            if self.mode == "status":
                if self._handle_status_key(key) == "quit":
                    return
            elif self.mode == "diff":
                if self._handle_diff_key(key) == "quit":
                    return
            elif self.mode == "graph":
                if self._handle_graph_key(key) == "quit":
                    return

    # ── Status data ─────────────────────────────────────────────────────────
    def refresh_status(self):
        out, _, _ = git("status", "--porcelain=v1")
        self.staged, self.unstaged = [], []
        for line in out.splitlines():
            if len(line) < 4:
                continue
            x, y = line[0], line[1]
            fname = line[3:]
            if x in "MADRC" and x != " ":
                self.staged.append((x, fname))
            if y in "MD?" or (x == "?" and y == "?"):
                st = "??" if x == "?" else y
                if not any(f == fname for _, f in self.unstaged):
                    self.unstaged.append((st, fname))
        self._build_items()
        out, _, _ = git("branch", "--show-current")
        self.branch = out.strip() or "HEAD"
        out, _, _ = git("log", "--oneline", "-n", "1")
        self.last_commit = out.strip()

    def _build_items(self):
        self.items = []
        self.items.append(("header", f"Staged Changes ({len(self.staged)})"))
        for s, f in self.staged:
            self.items.append(("staged", (s, f)))
        self.items.append(("header", f"Unstaged Changes ({len(self.unstaged)})"))
        for s, f in self.unstaged:
            self.items.append(("unstaged", (s, f)))
        if self.cursor >= len(self.items):
            self.cursor = max(0, len(self.items) - 1)

    # ── Help overlay ─────────────────────────────────────────────────────────
    def _draw_help(self, scr, h, w):
        lines = [
            ("", ""),
            ("  KEY BINDINGS", "header"),
            ("", ""),
            ("  ── Status View ──", "section"),
            ("  j / k          Navigate up/down", ""),
            ("  g / G          Jump to top/bottom", ""),
            ("  { / }          Jump between sections", ""),
            ("  s              Stage / unstage file", ""),
            ("  a              Stage all / unstage all", ""),
            ("  Enter / l      Open diff view", ""),
            ("  c              Commit (type message, Enter to confirm)", ""),
            ("  p              Git push", ""),
            ("  P              Git pull", ""),
            ("  f              Git fetch", ""),
            ("  b              Switch branch", ""),
            ("  d              Discard changes (y/n confirm)", ""),
            ("  r              Refresh", ""),
            ("  Tab            Switch to graph view", ""),
            ("", ""),
            ("  ── Diff View ──", "section"),
            ("  j / k          Scroll up/down", ""),
            ("  { / }          Page up/down (10 lines)", ""),
            ("  n / N          Next / previous hunk", ""),
            ("  a              Accept (stage file)", ""),
            ("  Esc / h        Back to status", ""),
            ("", ""),
            ("  ── Graph View ──", "section"),
            ("  j / k          Navigate up/down", ""),
            ("  g / G          Jump to top/bottom", ""),
            ("  { / }          Half page scroll", ""),
            ("  Enter          Expand/collapse commit files", ""),
            ("  d              Show diff for file", ""),
            ("  Tab            Switch to status view", ""),
            ("", ""),
            ("  ── Global ──", "section"),
            ("  ?              Toggle this help", ""),
            ("  q              Quit", ""),
            ("", ""),
            ("  Press any key to close", "dim"),
        ]
        try:
            scr.addnstr(0, 0, " " * w, w, self.cp("header") | curses.A_BOLD)
            scr.addnstr(0, 0, f" {ICON_GIT} Git Manager — Help", w - 1, self.cp("header") | curses.A_BOLD)
        except curses.error:
            pass

        for i, (text, kind) in enumerate(lines):
            row = 1 + i
            if row >= h - 1:
                break
            if kind == "header":
                attr = self.cp("purple") | curses.A_BOLD
            elif kind == "section":
                attr = self.cp("cyan") | curses.A_BOLD
            elif kind == "dim":
                attr = self.cp("comment")
            else:
                attr = self.cp("fg")
            try:
                scr.addnstr(row, 0, text[:w-1], w-1, attr)
            except curses.error:
                pass

    # ── Color helper ────────────────────────────────────────────────────────
    def cp(self, name):
        return curses.color_pair(self._pairs.get(name, 0))

    # ── Status draw ─────────────────────────────────────────────────────────
    def _draw_status(self, scr, h, w):
        # Header
        adds = len([s for s, _ in self.staged if s == "A"])
        mods = len([s for s, _ in self.staged if s == "M"]) + len([s for s, _ in self.unstaged if s == "M"])
        dels = len([s for s, _ in self.staged if s == "D"])
        title = f" {ICON_GIT} Git Status    {ICON_BRANCH} {self.branch}  +{adds} ~{mods} -{dels}"
        try:
            scr.addnstr(0, 0, " " * w, w, self.cp("header") | curses.A_BOLD)
            scr.addnstr(0, 0, title[:w-1], w-1, self.cp("header") | curses.A_BOLD)
        except curses.error:
            pass
        try:
            scr.addnstr(1, 0, "\u2500" * w, w, self.cp("grey"))
        except curses.error:
            pass

        content_h = h - 4
        if self.cursor < self.scroll:
            self.scroll = self.cursor
        if self.cursor >= self.scroll + content_h:
            self.scroll = self.cursor - content_h + 1

        for i in range(content_h):
            idx = self.scroll + i
            row = 2 + i
            if idx >= len(self.items):
                break
            kind, data = self.items[idx]
            is_cur = idx == self.cursor
            try:
                if kind == "header":
                    line = f" {data}"
                    if is_cur:
                        scr.addnstr(row, 0, " " * w, w, self.cp("cursor"))
                        scr.addnstr(row, 0, line[:w-1], w-1, self.cp("cursor") | curses.A_BOLD)
                    else:
                        scr.addnstr(row, 0, line[:w-1], w-1, self.cp("purple") | curses.A_BOLD)
                else:
                    st, fname = data
                    sc = STATUS_COLOR_NAMES.get(st, "fg")
                    icon = file_icon(fname)
                    line = f"   {st:<3}{icon}  {fname}"
                    if is_cur:
                        scr.addnstr(row, 0, " " * w, w, self.cp("cursor"))
                        scr.addnstr(row, 0, line[:w-1], w-1, self.cp("cursor") | curses.A_BOLD)
                        scr.addnstr(row, 3, st, len(st), self.cp(sc) | curses.A_BOLD)
                    else:
                        scr.addnstr(row, 0, line[:w-1], w-1, self.cp("fg"))
                        scr.addnstr(row, 3, st, len(st), self.cp(sc))
            except curses.error:
                pass

        # Separator + status bar
        try:
            scr.addnstr(h - 2, 0, "\u2500" * w, w, self.cp("grey"))
        except curses.error:
            pass
        help_text = " s:stage  a:all  c:commit  p:push  P:pull  f:fetch  b:branch  d:discard  Tab:graph  q:quit"
        try:
            scr.addnstr(h - 1, 0, " " * w, w, self.cp("statusbar"))
            scr.addnstr(h - 1, 0, help_text[:w-1], w-1, self.cp("statusbar"))
        except curses.error:
            pass
        if self.input_mode:
            prompt = f" {self.input_prompt}: {self.input_buf}\u2588"
            try:
                scr.addnstr(h - 1, 0, " " * w, w, self.cp("input") | curses.A_BOLD)
                scr.addnstr(h - 1, 0, prompt[:w-1], w-1, self.cp("input") | curses.A_BOLD)
            except curses.error:
                pass
        elif self.confirm_mode:
            try:
                scr.addnstr(h - 1, 0, " " * w, w, self.cp("confirm") | curses.A_BOLD)
                scr.addnstr(h - 1, 0, f" {self.message} (y/n)"[:w-1], w-1, self.cp("confirm") | curses.A_BOLD)
            except curses.error:
                pass
        elif self.message:
            try:
                scr.addnstr(h - 1, 0, " " * w, w, self.cp("statusbar"))
                msg = f" {ICON_COMMIT} {self.message}"
                scr.addnstr(h - 1, 0, msg[:w-1], w-1, self.cp("statusbar") | curses.A_BOLD)
            except curses.error:
                pass

    # ── Status keys ─────────────────────────────────────────────────────────
    def _handle_status_key(self, key):
        if key == ord("q"):
            return "quit"
        elif key == ord("j") or key == curses.KEY_DOWN:
            self.cursor = min(len(self.items) - 1, self.cursor + 1)
        elif key == ord("k") or key == curses.KEY_UP:
            self.cursor = max(0, self.cursor - 1)
        elif key == ord("g"):
            self.cursor = 0
        elif key == ord("G"):
            self.cursor = max(0, len(self.items) - 1)
        elif key == ord("{"):
            # Jump to prev section header
            for i in range(self.cursor - 1, -1, -1):
                if self.items[i][0] == "header":
                    self.cursor = i
                    break
        elif key == ord("}"):
            for i in range(self.cursor + 1, len(self.items)):
                if self.items[i][0] == "header":
                    self.cursor = i
                    break
        elif key == ord("s"):
            self._toggle_stage()
        elif key == ord("a"):
            self._stage_all_toggle()
        elif key in (10, 13, curses.KEY_ENTER, ord("l")):
            self._open_diff()
        elif key == ord("c"):
            self.input_mode = True
            self.input_buf = ""
            self.input_prompt = "Commit message"
            self.input_callback = self._do_commit
        elif key == ord("p"):
            self.message = "Pushing..."
            _, err, rc = git("push")
            self.message = "Pushed!" if rc == 0 else f"Push failed: {err.strip()[:50]}"
        elif key == ord("P"):
            self.message = "Pulling..."
            _, err, rc = git("pull")
            self.message = "Pulled!" if rc == 0 else f"Pull failed: {err.strip()[:50]}"
            self.refresh_status()
        elif key == ord("f"):
            _, err, rc = git("fetch")
            self.message = "Fetched!" if rc == 0 else f"Fetch failed: {err.strip()[:50]}"
        elif key == ord("b"):
            self._show_branches()
        elif key == ord("d"):
            self._discard_file()
        elif key == ord("r"):
            self.refresh_status()
            self.message = "Refreshed"
        elif key == 9:  # Tab
            self.mode = "graph"
            self.graph_cursor = 0
            self.graph_scroll = 0
            self.refresh_graph()
        return None

    def _toggle_stage(self):
        if self.cursor >= len(self.items):
            return
        kind, data = self.items[self.cursor]
        if kind == "staged":
            _, fname = data
            git("reset", "HEAD", "--", fname)
            self.refresh_status()
            self.message = f"Unstaged {fname}"
        elif kind == "unstaged":
            _, fname = data
            git("add", "--", fname)
            self.refresh_status()
            self.message = f"Staged {fname}"

    def _stage_all_toggle(self):
        if self.unstaged:
            git("add", "-A")
            self.message = "Staged all"
        else:
            git("reset", "HEAD")
            self.message = "Unstaged all"
        self.refresh_status()

    def _open_diff(self):
        if self.cursor >= len(self.items):
            return
        kind, data = self.items[self.cursor]
        if kind not in ("staged", "unstaged"):
            return
        st, fname = data
        self.diff_file = fname
        self.diff_staged = (kind == "staged")
        self._parse_diff()
        self.mode = "diff"
        self.diff_scroll = 0

    def _do_commit(self, msg):
        if not msg.strip():
            self.message = "Commit cancelled (empty message)"
            return
        _, err, rc = git("commit", "-m", msg)
        self.message = "Committed!" if rc == 0 else f"Commit failed: {err.strip()[:60]}"
        self.refresh_status()

    def _show_branches(self):
        out, _, _ = git("branch", "-a")
        branches = []
        for line in out.splitlines():
            b = line.strip().lstrip("* ").strip()
            if b and "HEAD" not in b:
                branches.append(b)
        self.input_mode = True
        self.input_buf = ""
        self.input_prompt = f"Switch branch ({', '.join(branches[:5])})"
        self.input_callback = self._do_switch_branch

    def _do_switch_branch(self, branch):
        if not branch.strip():
            self.message = "Cancelled"
            return
        _, err, rc = git("checkout", branch.strip())
        self.message = f"Switched to {branch.strip()}" if rc == 0 else f"Error: {err.strip()[:50]}"
        self.refresh_status()

    def _discard_file(self):
        if self.cursor >= len(self.items):
            return
        kind, data = self.items[self.cursor]
        if kind != "unstaged":
            self.message = "Can only discard unstaged changes"
            return
        st, fname = data
        self.message = f"Discard changes to {fname}?"
        self.confirm_mode = True
        self.confirm_callback = lambda: self._do_discard(fname)

    def _do_discard(self, fname):
        git("checkout", "--", fname)
        self.refresh_status()
        self.message = f"Discarded {fname}"

    # ── Input / confirm handling ────────────────────────────────────────────
    def _handle_input(self, key):
        if key == 27:  # Esc
            self.input_mode = False
            self.message = "Cancelled"
        elif key in (10, 13, curses.KEY_ENTER):
            self.input_mode = False
            if self.input_callback:
                self.input_callback(self.input_buf)
        elif key in (curses.KEY_BACKSPACE, 127, 8):
            self.input_buf = self.input_buf[:-1]
        elif 32 <= key <= 126:
            self.input_buf += chr(key)

    def _handle_confirm(self, key):
        self.confirm_mode = False
        if key == ord("y") and self.confirm_callback:
            self.confirm_callback()
        else:
            self.message = "Cancelled"

    # ── Diff parsing ────────────────────────────────────────────────────────
    def _parse_diff(self):
        if self.diff_staged:
            out, _, _ = git("diff", "--cached", "--", self.diff_file)
        else:
            out, _, _ = git("diff", "--", self.diff_file)
        self.diff_left, self.diff_right, self.diff_hunks = [], [], []
        self.diff_adds, self.diff_dels = 0, 0
        if not out.strip():
            self.diff_left = [(0, "(no diff available)", "ctx")]
            self.diff_right = [(0, "", "ctx")]
            return

        lines = out.splitlines()
        left_no = right_no = 0
        for line in lines:
            if line.startswith("@@"):
                self.diff_hunks.append(len(self.diff_left))
                # Parse @@ -start,count +start,count @@
                parts = line.split()
                try:
                    left_no = int(parts[1].split(",")[0].lstrip("-"))
                    right_no = int(parts[2].split(",")[0].lstrip("+"))
                except (IndexError, ValueError):
                    left_no = right_no = 0
                self.diff_left.append((0, line, "hunk"))
                self.diff_right.append((0, line, "hunk"))
            elif line.startswith("---") or line.startswith("+++"):
                continue
            elif line.startswith("diff") or line.startswith("index"):
                continue
            elif line.startswith("-"):
                self.diff_left.append((left_no, line[1:], "del"))
                self.diff_right.append((0, "", "empty"))
                left_no += 1
                self.diff_dels += 1
            elif line.startswith("+"):
                self.diff_left.append((0, "", "empty"))
                self.diff_right.append((right_no, line[1:], "add"))
                right_no += 1
                self.diff_adds += 1
            elif line.startswith(" "):
                self.diff_left.append((left_no, line[1:], "ctx"))
                self.diff_right.append((right_no, line[1:], "ctx"))
                left_no += 1
                right_no += 1

    # ── Diff draw ───────────────────────────────────────────────────────────
    def _draw_diff(self, scr, h, w):
        staged_label = "staged" if self.diff_staged else "unstaged"
        title = f" {file_icon(self.diff_file)} {self.diff_file}  [{self.diff_adds} additions, {self.diff_dels} deletions] ({staged_label})"
        try:
            scr.addnstr(0, 0, " " * w, w, self.cp("header") | curses.A_BOLD)
            scr.addnstr(0, 0, title[:w-1], w-1, self.cp("header") | curses.A_BOLD)
        except curses.error:
            pass
        try:
            scr.addnstr(1, 0, "\u2500" * w, w, self.cp("grey"))
        except curses.error:
            pass

        content_h = h - 4
        half = w // 2
        lno_w = 5

        if self.diff_scroll > len(self.diff_left) - content_h:
            self.diff_scroll = max(0, len(self.diff_left) - content_h)

        for i in range(content_h):
            idx = self.diff_scroll + i
            row = 2 + i
            if idx >= len(self.diff_left):
                break
            ln_l, txt_l, kind_l = self.diff_left[idx]
            ln_r, txt_r, kind_r = self.diff_right[idx]

            text_w = half - lno_w - 2

            # Left side
            if kind_l == "hunk":
                attr_l = self.cp("purple") | curses.A_BOLD
                lno_str = "  "
            elif kind_l == "del":
                attr_l = self.cp("diff_del")
                lno_str = f"{ln_l:>{lno_w}}"
            elif kind_l == "empty":
                attr_l = self.cp("comment") | curses.A_DIM
                lno_str = " " * lno_w
            else:
                attr_l = self.cp("fg")
                lno_str = f"{ln_l:>{lno_w}}" if ln_l else " " * lno_w

            try:
                scr.addnstr(row, 0, lno_str, lno_w, self.cp("lineno"))
                scr.addstr(row, lno_w, " ", self.cp("default"))
                display_l = txt_l[:text_w] if kind_l != "hunk" else txt_l[:half-1]
                scr.addnstr(row, lno_w + 1, display_l.ljust(text_w), text_w, attr_l)
                scr.addstr(row, half - 1, "\u2502", self.cp("grey"))
            except curses.error:
                pass

            # Right side
            rx = half
            if kind_r == "hunk":
                attr_r = self.cp("purple") | curses.A_BOLD
                rno_str = "  "
            elif kind_r == "add":
                attr_r = self.cp("diff_add")
                rno_str = f"{ln_r:>{lno_w}}"
            elif kind_r == "empty":
                attr_r = self.cp("comment") | curses.A_DIM
                rno_str = " " * lno_w
            else:
                attr_r = self.cp("fg")
                rno_str = f"{ln_r:>{lno_w}}" if ln_r else " " * lno_w

            try:
                scr.addnstr(row, rx, rno_str, lno_w, self.cp("lineno"))
                scr.addstr(row, rx + lno_w, " ", self.cp("default"))
                display_r = txt_r[:text_w]
                scr.addnstr(row, rx + lno_w + 1, display_r.ljust(text_w), text_w, attr_r)
            except curses.error:
                pass

        try:
            scr.addnstr(h - 2, 0, "\u2500" * w, w, self.cp("grey"))
        except curses.error:
            pass
        help_text = " j/k:scroll  n/N:next/prev hunk  a:accept(stage)  Esc/h:back  q:quit"
        try:
            scr.addnstr(h - 1, 0, " " * w, w, self.cp("statusbar"))
            scr.addnstr(h - 1, 0, help_text[:w-1], w-1, self.cp("statusbar"))
        except curses.error:
            pass

    # ── Diff keys ───────────────────────────────────────────────────────────
    def _handle_diff_key(self, key):
        if key == ord("q"):
            return "quit"
        elif key in (27, ord("h")):  # Esc or h
            self.mode = "status"
        elif key == ord("j") or key == curses.KEY_DOWN:
            self.diff_scroll = min(self.diff_scroll + 1, max(0, len(self.diff_left) - 1))
        elif key == ord("k") or key == curses.KEY_UP:
            self.diff_scroll = max(0, self.diff_scroll - 1)
        elif key == ord("}"):
            self.diff_scroll = min(self.diff_scroll + 10, max(0, len(self.diff_left) - 1))
        elif key == ord("{"):
            self.diff_scroll = max(0, self.diff_scroll - 10)
        elif key == ord("n"):
            for pos in self.diff_hunks:
                if pos > self.diff_scroll:
                    self.diff_scroll = pos
                    break
        elif key == ord("N"):
            for pos in reversed(self.diff_hunks):
                if pos < self.diff_scroll:
                    self.diff_scroll = pos
                    break
        elif key == ord("a"):
            git("add", "--", self.diff_file)
            self.message = "Hunk accepted (file staged)"
            self.mode = "status"
            self.refresh_status()
        return None

    # ── Graph data ──────────────────────────────────────────────────────────
    def refresh_graph(self):
        out, _, _ = git("log", "--all", "--graph", "--oneline", "--decorate",
                        "--format=format:%h|%s|%cr|%an|%d", "-50")
        self.graph_lines = []
        for line in out.splitlines():
            # Split graph chars from commit data
            # Find first commit hash pattern after graph chars
            idx = 0
            while idx < len(line) and line[idx] in " *|\\/_ ":
                idx += 1
            graph = line[:idx]
            rest = line[idx:]
            parts = rest.split("|", 4)
            if len(parts) >= 4:
                h, subj, date, author = parts[0], parts[1], parts[2], parts[3]
                deco = parts[4] if len(parts) > 4 else ""
                self.graph_lines.append((graph, h.strip(), subj.strip(), date.strip(), author.strip(), deco.strip()))
            else:
                self.graph_lines.append((line, "", "", "", "", ""))
        self.graph_expanded = {}
        self._build_graph_items()

    def _build_graph_items(self):
        self.graph_items = []
        for i, gl in enumerate(self.graph_lines):
            self.graph_items.append(("commit", i, gl))
            ghash = gl[1]
            if ghash in self.graph_expanded and self.graph_expanded[ghash]:
                for finfo in self.graph_expanded[ghash]:
                    self.graph_items.append(("file", i, finfo))

    # ── Graph draw ──────────────────────────────────────────────────────────
    def _draw_graph(self, scr, h, w):
        title = f" {ICON_GIT} Git Graph    {ICON_BRANCH} {self.branch}"
        try:
            scr.addnstr(0, 0, " " * w, w, self.cp("header") | curses.A_BOLD)
            scr.addnstr(0, 0, title[:w-1], w-1, self.cp("header") | curses.A_BOLD)
        except curses.error:
            pass
        try:
            scr.addnstr(1, 0, "\u2500" * w, w, self.cp("grey"))
        except curses.error:
            pass

        content_h = h - 4
        if self.graph_cursor < self.graph_scroll:
            self.graph_scroll = self.graph_cursor
        if self.graph_cursor >= self.graph_scroll + content_h:
            self.graph_scroll = self.graph_cursor - content_h + 1

        for i in range(content_h):
            idx = self.graph_scroll + i
            row = 2 + i
            if idx >= len(self.graph_items):
                break
            kind = self.graph_items[idx][0]
            is_cur = idx == self.graph_cursor

            try:
                if is_cur:
                    scr.addnstr(row, 0, " " * w, w, self.cp("cursor"))

                if kind == "commit":
                    graph, ghash, subj, date, author, deco = self.graph_items[idx][2]
                    x = 1
                    scr.addnstr(row, x, graph, len(graph), self.cp("purple") | curses.A_BOLD)
                    x += len(graph)
                    if ghash:
                        scr.addnstr(row, x, ghash, len(ghash), self.cp("yellow"))
                        x += len(ghash) + 1
                        max_subj = w - x - 30
                        if max_subj > 0:
                            s = subj[:max_subj]
                            scr.addnstr(row, x, s, len(s), self.cp("fg") | (curses.A_BOLD if is_cur else 0))
                            x += len(s) + 1
                        if x + len(date) + 2 < w:
                            scr.addnstr(row, x, f"({date})", len(date) + 2, self.cp("grey"))
                            x += len(date) + 3
                        if x + len(author) < w:
                            scr.addnstr(row, x, author, len(author), self.cp("cyan"))
                            x += len(author) + 1
                        if deco and x + len(deco) < w:
                            attr = self.cp("green") | curses.A_BOLD if "HEAD" in deco else self.cp("green")
                            scr.addnstr(row, x, deco, len(deco), attr)
                else:  # file
                    finfo = self.graph_items[idx][2]
                    st, fname = finfo[0], finfo[1:]
                    sc = STATUS_COLOR_NAMES.get(st.strip(), "fg")
                    icon = file_icon(fname.strip())
                    line = f"       {st} {icon}  {fname.strip()}"
                    if is_cur:
                        scr.addnstr(row, 0, line[:w-1], w-1, self.cp("cursor") | curses.A_BOLD)
                    else:
                        scr.addnstr(row, 0, line[:w-1], w-1, self.cp(sc))
            except curses.error:
                pass

        try:
            scr.addnstr(h - 2, 0, "\u2500" * w, w, self.cp("grey"))
        except curses.error:
            pass
        help_text = " j/k:nav  Enter:expand  d:diff  {/}:page  g/G:top/bottom  Tab:status  q:quit"
        try:
            scr.addnstr(h - 1, 0, " " * w, w, self.cp("statusbar"))
            scr.addnstr(h - 1, 0, help_text[:w-1], w-1, self.cp("statusbar"))
        except curses.error:
            pass

    # ── Graph keys ──────────────────────────────────────────────────────────
    def _handle_graph_key(self, key):
        if key == ord("q"):
            return "quit"
        elif key == ord("j") or key == curses.KEY_DOWN:
            self.graph_cursor = min(len(self.graph_items) - 1, self.graph_cursor + 1)
        elif key == ord("k") or key == curses.KEY_UP:
            self.graph_cursor = max(0, self.graph_cursor - 1)
        elif key == ord("g"):
            self.graph_cursor = 0
        elif key == ord("G"):
            self.graph_cursor = max(0, len(self.graph_items) - 1)
        elif key == ord("{"):
            h = curses.LINES - 4
            self.graph_cursor = max(0, self.graph_cursor - h // 2)
        elif key == ord("}"):
            h = curses.LINES - 4
            self.graph_cursor = min(len(self.graph_items) - 1, self.graph_cursor + h // 2)
        elif key in (10, 13, curses.KEY_ENTER):
            self._toggle_graph_expand()
        elif key == ord("d"):
            self._graph_show_diff()
        elif key == 9:  # Tab
            self.mode = "status"
            self.refresh_status()
        return None

    def _toggle_graph_expand(self):
        if self.graph_cursor >= len(self.graph_items):
            return
        kind = self.graph_items[self.graph_cursor][0]
        if kind != "commit":
            return
        gl = self.graph_items[self.graph_cursor][2]
        ghash = gl[1]
        if not ghash:
            return
        if ghash in self.graph_expanded:
            del self.graph_expanded[ghash]
        else:
            out, _, _ = git("diff-tree", "--no-commit-id", "--name-status", "-r", ghash)
            files = []
            for line in out.splitlines():
                if line.strip():
                    files.append(line)
            self.graph_expanded[ghash] = files if files else None
        self._build_graph_items()

    def _graph_show_diff(self):
        if self.graph_cursor >= len(self.graph_items):
            return
        item = self.graph_items[self.graph_cursor]
        if item[0] != "file":
            return
        finfo = item[2]
        fname = finfo[1:].strip()
        commit_idx = item[1]
        ghash = self.graph_lines[commit_idx][1]
        # Show diff for this file in this commit
        out, _, _ = git("diff", f"{ghash}~1", ghash, "--", fname)
        if not out.strip():
            out, _, _ = git("show", f"{ghash}", "--", fname)
        self.diff_file = fname
        self.diff_staged = False
        # Parse the raw diff
        self.diff_left, self.diff_right, self.diff_hunks = [], [], []
        self.diff_adds = self.diff_dels = 0
        left_no = right_no = 0
        for line in out.splitlines():
            if line.startswith("@@"):
                self.diff_hunks.append(len(self.diff_left))
                parts = line.split()
                try:
                    left_no = int(parts[1].split(",")[0].lstrip("-"))
                    right_no = int(parts[2].split(",")[0].lstrip("+"))
                except (IndexError, ValueError):
                    left_no = right_no = 0
                self.diff_left.append((0, line, "hunk"))
                self.diff_right.append((0, line, "hunk"))
            elif line.startswith("---") or line.startswith("+++"):
                continue
            elif line.startswith("diff") or line.startswith("index"):
                continue
            elif line.startswith("-"):
                self.diff_left.append((left_no, line[1:], "del"))
                self.diff_right.append((0, "", "empty"))
                left_no += 1
                self.diff_dels += 1
            elif line.startswith("+"):
                self.diff_left.append((0, "", "empty"))
                self.diff_right.append((right_no, line[1:], "add"))
                right_no += 1
                self.diff_adds += 1
            elif line.startswith(" "):
                self.diff_left.append((left_no, line[1:], "ctx"))
                self.diff_right.append((right_no, line[1:], "ctx"))
                left_no += 1
                right_no += 1
        if not self.diff_left:
            self.diff_left = [(0, "(no diff)", "ctx")]
            self.diff_right = [(0, "", "ctx")]
        self.diff_scroll = 0
        self.mode = "diff"


def main():
    os.environ.setdefault("TERM", "xterm-256color")
    app = GitManager()
    curses.wrapper(app.run)


if __name__ == "__main__":
    main()
