#!/usr/bin/env python3
# framework/hooks/claude-code/statusline.py
# Claude Code statusline for morph-spec.
# Receives JSON via stdin from Claude Code after each response.
#
# DESIGN RULE — the payload is the source of truth.
# Anything Claude Code already computes (context %, rate limits, worktree,
# repo, PR) is READ from stdin, never recomputed. Recomputing is what made
# previous versions drift away from /context and /usage. Only two things come
# from outside the payload: git diff stats and the MORPH feature state.
#
# ONE LINE — the layout is deliberately a single row:
#
#   <model·effort> │ <context bar ↻ tokens-to-compact> │ <5h> │ <7d> │ <branch> │ <PR>
#
# The only second line is the MORPH block, and only inside a secondary
# worktree. Everything else that used to occupy rows — the session name, the
# session clock, the last Skill/Agent, the output style — was cut as noise.
# Dropping the Skill/Agent widgets is also what lets this script stop parsing
# the transcript entirely.
#
# The statusline is STATELESS — it never writes to disk. An earlier version
# cached session start times per-cwd and per-transcript; the cwd cache leaked
# start times between concurrent windows and the directory grew to thousands
# of files. Removing the clock removed the need for any of it.

import sys
import json
import os
import subprocess
import re
import time
import traceback
from pathlib import Path

# Ensure UTF-8 output on Windows (stdout defaults to CP1252 otherwise)
if hasattr(sys.stdout, 'reconfigure'):
    sys.stdout.reconfigure(encoding='utf-8')

# ANSI colors
R = '\033[0m'       # Reset
BOLD = '\033[1m'
CYAN = '\033[36m'
GREEN = '\033[32m'
YELLOW = '\033[33m'
RED = '\033[31m'
BLUE = '\033[34m'
GRAY = '\033[90m'
WHITE = '\033[97m'

# Windows: flag to hide console window when spawning subprocesses
_CREATE_NO_WINDOW = 0x08000000 if os.name == 'nt' else 0

SEP = f" {GRAY}│{R} "


# ── MORPH framework constants ────────────────────────────────────────────────

# Mirrors derivePhase() in src/core/state/state-manager.js — the first phase
# (scanning backwards) whose CANONICAL artifact exists wins. An empty folder
# is not enough.
PHASE_MAP = [
    ('4-review',    'review',    'review-report.md'),
    ('3-implement', 'implement', 'recap.md'),
    ('2-plan',      'plan',      'spec.md'),
    ('1-design',    'uiux',      'design-system.md'),
    ('0-proposal',  'proposal',  'proposal.md'),
]
PHASE_INDEX = {'proposal': 0, 'uiux': 1, 'plan': 2, 'implement': 3, 'review': 4}
PHASE_LABELS = {
    'proposal':  'proposal',
    'uiux':      'UI/UX',
    'plan':      'planning',
    'implement': 'implement',
    'review':    'review',
}
PIPELINE_TOTAL = 5

# Gates that can block; `implement` has none. Mirrors feature.schema.json.
GATE_PHASES = ('proposal', 'uiux', 'plan', 'review')


# ── Auto-compact threshold ───────────────────────────────────────────────────
#
# Mirrors claude.exe v2.1.220. The context bar's headline number is the
# payload's own `used_percentage` (a fraction of the MODEL window), but
# auto-compact fires much earlier, against a different budget:
#
#   l7(model, cfg).window = min(modelWindow, autoCompactWindow)   [settings]
#   HSe(model, cfg)       = window - min(maxOutputTokens, 20000)
#   wfo(e)                = e - 13000
#   threshold             = wfo(HSe(...))
#
# maxOutputTokens is >= 20000 on every current model, so the output reserve is
# a flat 20000. This is only reproducible when `autoCompactWindow` is set
# explicitly in settings (source "settings"); every other source (clientdata,
# experiment, model-default, auto) is server-driven and cannot be mirrored
# here, so the widget hides itself instead of guessing.
COMPACT_OUTPUT_RESERVE = 20000
COMPACT_HEADROOM = 13000


# ── General helpers ──────────────────────────────────────────────────────────

def safe_int(val, default=0):
    """Safely convert value to int, handling None from JSON null."""
    if val is None:
        return default
    try:
        return int(val)
    except (TypeError, ValueError):
        return default


def safe_float(val, default=0.0):
    """Safely convert value to float, handling None from JSON null."""
    if val is None:
        return default
    try:
        return float(val)
    except (TypeError, ValueError):
        return default


def usage_color(pct):
    """Color for a 0-100 usage gauge. Thresholds from the Claude Code docs."""
    if pct < 70:
        return GREEN
    if pct < 90:
        return YELLOW
    return RED


_EIGHTHS = '▏▎▍▌▋▊▉'


def progress_bar(pct, width=8):
    """Gauge with sub-cell resolution.

    Whole blocks alone are too coarse for the 5-wide rate-limit gauges: at
    width 5 a real 13% would render completely empty. The eighth-block glyphs
    keep small-but-nonzero values visible.
    """
    pct = max(0.0, min(100.0, pct))
    exact = pct / 100 * width
    filled = int(exact)
    bar = '█' * filled
    if filled < width:
        remainder = int((exact - filled) * 8)
        if remainder:
            bar += _EIGHTHS[remainder - 1]
    return bar + '░' * (width - len(bar))


def format_tokens(n):
    if n >= 1_000_000:
        value = f"{n / 1_000_000:.1f}"
        return f"{value[:-2] if value.endswith('.0') else value}M"
    if n >= 1000:
        return f"{n // 1000}k"
    return str(n)


def format_countdown(epoch):
    """Time until a Unix epoch: 47m, 1h48, 6d16. None when already past."""
    remaining = epoch - time.time()
    if remaining <= 0:
        return None
    days, rest = int(remaining // 86400), int(remaining % 86400)
    hours, minutes = rest // 3600, (rest % 3600) // 60
    if days:
        return f"{days}d{hours:02d}"
    if hours:
        return f"{hours}h{minutes:02d}"
    return f"{minutes}m"


# Both CSI colour codes and OSC sequences. The OSC branch matters: an OSC 8
# hyperlink carries the whole URL as an invisible payload, and counting it
# would make a 9-character PR badge measure the length of its GitHub URL.
_ANSI_RE = re.compile(r'\033\][^\a\033]*(?:\a|\033\\)|\033\[[0-9;]*m')

# Glyphs terminals render double-width. Deliberately narrow: the block and
# arrow glyphs this script draws gauges with (█ ░ ▏▎▍▌▋▊▉ ↑ ↓ ↻ ► ✓ ✗) are all
# SINGLE width, and treating the whole U+2100+ range as wide overcounted the
# budget line by ~20 columns, dropping widgets that would have fit.
#
# Any new glyph below U+1F300 must be listed here or it is measured as 1.
# ⏳ (U+23F3) is the live case: it has Emoji_Presentation=Yes so it renders
# double, unlike its lookalike ⏱ (U+23F1), which renders as narrow text.
_WIDE_SINGLES = frozenset('⚡⛔⬛⬜✅❌⏳')


def char_width(ch):
    code = ord(ch)
    if 0x1F300 <= code <= 0x1FAFF:   # emoji planes
        return 2
    return 2 if ch in _WIDE_SINGLES else 1


def visible_width(text):
    """Rendered width: escape sequences stripped, emoji counted double."""
    return sum(char_width(ch) for ch in _ANSI_RE.sub('', text))


def join_to_width(parts, columns):
    """Join `parts` with SEP, dropping the lowest-priority ones until it fits.

    `parts` is a list of (priority, text); lower priority number = keep longer.
    `columns` comes from the COLUMNS env var Claude Code exports before running
    the status line (tput does not work — stdout is captured, not a terminal).
    """
    kept = [p for p in parts if p[1]]
    if not kept:
        return ''
    sep_width = visible_width(SEP)

    def total(items):
        return (sum(visible_width(text) for _, text in items)
                + sep_width * max(0, len(items) - 1))

    if columns:
        # Drop the worst priority first, but never drop the single best item.
        while len(kept) > 1 and total(kept) > columns:
            worst = max(range(len(kept)), key=lambda i: kept[i][0])
            kept.pop(worst)
    return SEP.join(text for _, text in kept)


def read_user_settings():
    """Read the user settings (for autoCompactWindow). Never raises.

    Honours CLAUDE_CONFIG_DIR, the same override Claude Code itself uses to
    relocate ~/.claude.
    """
    try:
        base = os.environ.get('CLAUDE_CONFIG_DIR')
        path = (Path(base) if base else Path.home() / '.claude') / 'settings.json'
        data = json.loads(path.read_text(encoding='utf-8'))
        return data if isinstance(data, dict) else {}
    except Exception:
        return {}


# ── Git helpers ───────────────────────────────────────────────────────────────

def _run_git(args, cwd):
    """Run a git command safely on Windows (no console popup, with timeout)."""
    return subprocess.check_output(
        ['git'] + args,
        cwd=cwd, stderr=subprocess.DEVNULL, timeout=3,
        creationflags=_CREATE_NO_WINDOW,
    ).decode('utf-8', 'replace').strip()


def get_git_info(cwd):
    """Branch, ahead/behind, changed files and line diff.

    A single `status --porcelain=v2 --branch -uno` covers branch, upstream
    divergence and the file count. `-uno` matters: v2 lists untracked entries
    by default, which would silently change what the file count means. The
    shortstat call is skipped entirely on a clean tree.
    """
    try:
        out = _run_git(['status', '--porcelain=v2', '--branch', '-uno'], cwd)
    except Exception:
        return ''

    branch, ahead, behind, files = '', 0, 0, 0
    for line in out.splitlines():
        if line.startswith('# branch.head '):
            branch = line[len('# branch.head '):].strip()
        elif line.startswith('# branch.ab '):
            # Only present when an upstream is configured.
            match = re.match(r'# branch\.ab \+(\d+) -(\d+)', line)
            if match:
                ahead, behind = int(match.group(1)), int(match.group(2))
        elif line[:1] in ('1', '2', 'u'):
            files += 1

    if not branch:
        return ''

    ins, dels = 0, 0
    if files:
        try:
            stat = _run_git(['diff', 'HEAD', '--shortstat'], cwd)
            match = re.search(r'(\d+) insertion', stat)
            if match:
                ins = int(match.group(1))
            match = re.search(r'(\d+) deletion', stat)
            if match:
                dels = int(match.group(1))
        except Exception:
            pass

    parts = [f"{BLUE} {branch}{R}"]
    if ahead or behind:
        divergence = ''
        if ahead:
            divergence += f"{GREEN}↑{ahead}{R}"
        if behind:
            divergence += f"{RED}↓{behind}{R}"
        parts.append(divergence)
    if ins or dels:
        parts.append(f"{GREEN}+{ins}{RED}-{dels}{R}")
    return ' '.join(parts)


# ── Transcript helpers ────────────────────────────────────────────────────────

def get_worktree_label(payload, cwd):
    """Worktree name for this session, or None when in the primary worktree.

    Cascade: the payload's own `worktree` (--worktree sessions), then
    `workspace.git_worktree` (any linked worktree), then — only when both are
    absent AND this looks like a morph project — the git subprocess earlier
    versions always paid for. The fallback keeps behaviour identical on builds
    or worktree layouts where those fields do not appear.
    """
    worktree = payload.get('worktree') or {}
    if worktree.get('name'):
        branch = (worktree.get('branch') or '').replace('refs/heads/', '')
        return branch or worktree['name']

    git_worktree = (payload.get('workspace') or {}).get('git_worktree')
    if git_worktree:
        return git_worktree

    if not (Path(cwd) / '.morph' / 'features').is_dir():
        return None
    try:
        out = _run_git(['worktree', 'list', '--porcelain'], cwd)
    except Exception:
        return None
    entries, current = [], {}
    for line in out.splitlines():
        if line.startswith('worktree '):
            if current:
                entries.append(current)
            current = {'path': line.split(' ', 1)[1]}
        elif line.startswith('branch '):
            current['branch'] = line.split(' ', 1)[1]
    if current:
        entries.append(current)
    if len(entries) > 1:
        try:
            cwd_resolved = str(Path(cwd).resolve())
        except Exception:
            return None
        for entry in entries[1:]:  # entry 0 is always the primary worktree
            try:
                same = str(Path(entry.get('path', '')).resolve()) == cwd_resolved
            except Exception:
                same = False
            if same:
                return entry.get('branch', '').replace('refs/heads/', '') or None
    return None


def _gate_implied_phase(gates):
    """Phase implied purely by approved gates (mirrors state-manager.js)."""
    def approved(gate):
        return (gates.get(gate) or {}).get('approved') is True
    if approved('review'):
        return 'review'
    if approved('plan'):
        return 'implement'
    if approved('uiux'):
        return 'plan'
    return 'proposal'


def derive_phase(feature_dir, gates):
    """Phase from the filesystem, bumped by approved gates."""
    artifact_phase = 'proposal'
    for folder, phase, canonical in PHASE_MAP:
        if (feature_dir / folder / canonical).exists():
            artifact_phase = phase
            break
    implied = _gate_implied_phase(gates)
    if PHASE_INDEX[implied] > PHASE_INDEX[artifact_phase]:
        return implied
    return artifact_phase


def count_tasks(feature_dir, feature_json):
    """(done, total) from 2-plan/tasks.json — the declared source of truth.

    `skipped` counts as resolved: it will never be worked on, and leaving it
    out would pin the bar below 100%. Falls back to feature.json's aggregate
    counters, which the schema marks as informational.
    """
    try:
        raw = (feature_dir / '2-plan' / 'tasks.json').read_text(encoding='utf-8')
        tasks = json.loads(raw).get('tasks')
        if isinstance(tasks, list) and tasks:
            done = sum(1 for task in tasks if task.get('status') in ('done', 'skipped'))
            return done, len(tasks)
    except Exception:
        pass
    aggregate = feature_json.get('tasks') or {}
    return safe_int(aggregate.get('completed')), safe_int(aggregate.get('total'))


def get_active_features(cwd):
    """in_progress features from the authoritative per-feature state.

    Reads .morph/features/*/feature.json. The root .morph/state.json is a thin,
    gitignored, derivable index and must not be trusted here.
    """
    base = Path(cwd) / '.morph' / 'features'
    if not base.is_dir():
        return []
    try:
        feature_files = sorted(base.glob('*/feature.json'))
    except Exception:
        return []

    result = []
    for path in feature_files:
        try:
            data = json.loads(path.read_text(encoding='utf-8'))
        except Exception:
            continue
        if not isinstance(data, dict) or data.get('status') != 'in_progress':
            continue
        feature_dir = path.parent
        gates = data.get('approvalGates') or {}
        phase = derive_phase(feature_dir, gates)
        done, total = count_tasks(feature_dir, data)
        pending = None
        if phase in GATE_PHASES and (gates.get(phase) or {}).get('approved') is not True:
            pending = phase
        result.append({
            'name':        feature_dir.name,
            'phase':       phase,
            'tasks_done':  done,
            'tasks_total': total,
            'pending':     pending,
        })
    return result


def get_last_hook(cwd):
    """Name of the most recent hook from .morph/logs/activity.json.

    The age is deliberately not shown: activity-logger.js only records
    HH:MM:SS, so any age computed from it is wrong across midnight.
    """
    try:
        raw = (Path(cwd) / '.morph' / 'logs' / 'activity.json').read_text(encoding='utf-8')
        hooks = json.loads(raw).get('hooks') or []
        return hooks[-1].get('name') or None if hooks else None
    except Exception:
        return None


# ── Line renderers ────────────────────────────────────────────────────────────

def render_morph_line(feature, hook_name, columns):
    """Line 0 — the active MORPH feature.

    Goes through join_to_width like the status line: a feature name plus phase,
    task bar, gate and hook is the longest thing this script prints, and a
    wrapping line 0 would defeat the whole point of the single-row layout.
    """
    parts = [(0, f"{CYAN}{BOLD}► {feature['name']}{R}")]

    phase = feature['phase']
    label = PHASE_LABELS.get(phase, phase)
    position = PHASE_INDEX.get(phase)
    if position is None:
        parts.append((1, f"{CYAN}{label}{R}"))
    else:
        parts.append((1, f"{CYAN}{label} {position + 1}/{PIPELINE_TOTAL}{R}"))

    if feature['tasks_total'] > 0:
        pct = feature['tasks_done'] / feature['tasks_total'] * 100
        parts.append((
            3,
            f"{GREEN}{progress_bar(pct, 6)} "
            f"{feature['tasks_done']}/{feature['tasks_total']} tasks{R}"
        ))

    if feature['pending']:
        parts.append((2, f"{RED}⛔ gate: {feature['pending']}{R}"))

    if hook_name:
        parts.append((4, f"{GRAY}🪝 {hook_name}{R}"))

    return join_to_width(parts, columns)


def effective_window(payload, settings):
    """The window /context measures against, or None when not reproducible.

    NOT the model window. Measured against Claude Code 2.1.220 with
    `autoCompactWindow: 680000`:

        payload   total_input_tokens 289681, context_window_size 1000000,
                  used_percentage 29        (= 289681 / 1000000)
        /context  "289.7k/680k tokens (43%)" (= 289681 / 680000)

    So the payload's own `used_percentage` divides by the MODEL window while
    the app divides by the effective one. Taking `used_percentage` verbatim —
    which is what this script did — is a 1.47x understatement at these
    settings, and it is the divergence the whole rewrite set out to kill.
    """
    model_window = safe_int((payload.get('context_window') or {}).get('context_window_size'))
    if model_window <= 0:
        return None
    if settings.get('autoCompactEnabled') is False:
        return model_window
    configured = safe_int(settings.get('autoCompactWindow'))
    # Every other source of the window (clientdata, experiment, model-default,
    # auto) is server-driven and cannot be mirrored here.
    return min(model_window, configured) if configured > 0 else None


def compact_threshold(payload, settings):
    """Tokens at which auto-compact fires, or None when not reproducible."""
    if settings.get('autoCompactEnabled') is False:
        return None
    if safe_int(settings.get('autoCompactWindow')) <= 0:
        return None
    window = effective_window(payload, settings)
    if not window:
        return None
    # /context calls this the "Autocompact buffer" and reports it as 33k —
    # exactly COMPACT_OUTPUT_RESERVE + COMPACT_HEADROOM.
    threshold = window - COMPACT_OUTPUT_RESERVE - COMPACT_HEADROOM
    return threshold if threshold > 0 else None


def render_status_line(payload, cwd, settings, columns):
    """The single status line — where you are and what you have left.

    One bar only, on the context: it is the budget that moves fastest and the
    one you act on. The account windows are numbers, coloured by the same
    thresholds. Priority governs what a narrow terminal drops — the context
    never goes, the weekly window goes first.

    Deliberately NOT rendered, by the user's call: the session name, the
    session clock, the last Skill/Agent, and the output style. Dropping the
    last two is also what lets this script stop reading the transcript at all.
    """
    parts = []  # (priority, text) — the highest priority number is dropped first

    model = payload.get('model') or {}
    model_name = (model.get('display_name') or model.get('id') or ''
                  if isinstance(model, dict) else str(model or ''))
    if model_name:
        short = (model_name
                 .replace('Claude ', '')
                 .replace('claude-', '')
                 .replace(' (1M context)', ' 1M')
                 .replace(' (claude.ai)', ''))
        # Only surface state that is NOT the default: thinking is normally on,
        # fast mode normally off. `permission_mode` is deliberately absent —
        # Claude Code never sends it to the status line, only to hooks.
        state = []
        effort = (payload.get('effort') or {}).get('level')
        if effort:
            state.append(effort)
        if (payload.get('thinking') or {}).get('enabled') is False:
            state.append('no-think')
        if payload.get('fast_mode'):
            state.append('fast')
        suffix = f"{GRAY}·{chr(183).join(state)}{R}" if state else ''
        parts.append((3, f"{WHITE}{short}{R}{suffix}"))

    context = payload.get('context_window') or {}
    if context:
        model_window = safe_int(context.get('context_window_size'))
        tokens = safe_int(context.get('total_input_tokens'))
        if not tokens:
            # Null right after /compact and before the first API response.
            # Recompute with the app's own input-only formula — output_tokens
            # is NOT part of it, and adding it is how earlier versions drifted.
            usage = context.get('current_usage')
            if isinstance(usage, dict):
                tokens = (safe_int(usage.get('input_tokens'))
                          + safe_int(usage.get('cache_creation_input_tokens'))
                          + safe_int(usage.get('cache_read_input_tokens')))

        # Divide by the same window /context does. Only when that window is
        # not reproducible do we fall back to the payload's own percentage,
        # which is measured against the model window instead.
        window = effective_window(payload, settings)
        pct = None
        if window and tokens:
            pct = tokens / window * 100
        elif context.get('used_percentage') is not None:
            pct = safe_float(context.get('used_percentage'))
        elif model_window and tokens:
            pct = tokens / model_window * 100

        if pct is not None:
            chunk = f"{usage_color(pct)}{progress_bar(pct, 8)} {pct:.0f}%{R}"
            # How much still fits before auto-compact fires — /context reports
            # the same number as "Free space". Same ↻ as the account windows:
            # "how much is left until the next reset", each in its own unit —
            # tokens here, time there.
            threshold = compact_threshold(payload, settings)
            if threshold and tokens:
                left = threshold - tokens
                if left <= 0:
                    chunk += f" {RED}⚠ compactando{R}"
                else:
                    left_pct = left / threshold * 100
                    color = GRAY if left_pct > 25 else (YELLOW if left_pct > 10 else RED)
                    chunk += f" {color}↻ {format_tokens(left)}{R}"
            parts.append((0, chunk))

    # Claude.ai subscription windows. Present only for Pro/Max subscribers
    # after the first API response; each window may be absent on its own.
    #
    # ⏳ is /usage's "Current session" and 📅 its "Current week (all models)".
    # The icons cost the same two columns the "5h"/"7d" labels did, and they
    # sidestep a real ambiguity: the app calls the short one a "session", but
    # it is a 5-hour billing window that resets on its own clock, unrelated to
    # the Claude Code session.
    rate_limits = payload.get('rate_limits') or {}
    for key, label, priority, countdown in (('five_hour', '⏳', 2, True),
                                            ('seven_day', '📅', 5, False)):
        window = rate_limits.get(key) or {}
        raw_pct = window.get('used_percentage')
        if raw_pct is None:
            continue
        pct = safe_float(raw_pct)
        chunk = f"{GRAY}{label}{R} {usage_color(pct)}{pct:.0f}%{R}"
        resets_at = window.get('resets_at') if countdown else None
        if resets_at:
            until = format_countdown(safe_float(resets_at))
            if until:
                chunk += f" {GRAY}↻ {until}{R}"
        parts.append((priority, chunk))

    git_info = get_git_info(cwd)
    if git_info:
        parts.append((1, git_info))

    pull_request = payload.get('pr') or {}
    if pull_request.get('number'):
        review_state = pull_request.get('review_state')
        mark = {'approved': '✓', 'changes_requested': '✗',
                'draft': '·', 'pending': '…'}.get(review_state, '')
        color = {'approved': GREEN, 'changes_requested': RED,
                 'draft': GRAY}.get(review_state, YELLOW)
        label = f"PR #{pull_request['number']} {mark}".strip()
        url = pull_request.get('url')
        if url:
            # OSC 8 hyperlink — honoured by Windows Terminal, iTerm2, Kitty.
            # visible_width() strips the OSC payload, so the URL costs no columns.
            label = f"\033]8;;{url}\033\\{label}\033]8;;\033\\"
        parts.append((4, f"{color}{label}{R}"))

    return join_to_width(parts, columns)


# ── Main ──────────────────────────────────────────────────────────────────────

def main():
    try:
        raw = sys.stdin.read()
        if not raw.strip():
            sys.exit(0)
        payload = json.loads(raw)
        if not isinstance(payload, dict):
            sys.exit(0)
    except Exception:
        sys.exit(0)

    try:
        workspace = payload.get('workspace') or {}
        cwd = workspace.get('current_dir') or payload.get('cwd') or os.getcwd()
        settings = read_user_settings()

        # Claude Code exports COLUMNS before running the command; `tput cols`
        # does not work here because stdout is captured, not a terminal.
        columns = safe_int(os.environ.get('COLUMNS'), 0)

        # The MORPH line is the ONLY second line, and only inside a secondary
        # worktree. In the primary tree it resurrects stale feature names
        # during unrelated work, so there the status line is a single row.
        if get_worktree_label(payload, cwd):
            hook_name = get_last_hook(cwd)
            for feature in get_active_features(cwd):
                print(render_morph_line(feature, hook_name, columns))
                hook_name = None  # annotate the first line only

        status_line = render_status_line(payload, cwd, settings, columns)
        if status_line:
            print(status_line)

    except Exception:
        err = traceback.format_exc().splitlines()[-1]
        print(f"{RED}ERRO: {err}{R}")


if __name__ == '__main__':
    main()
