#!/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

import sys
import json
import os
import subprocess
import time
import re
import hashlib
import traceback
from pathlib import Path
from datetime import datetime, timezone

# 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'
MAGENTA = '\033[35m'
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


# ── MORPH framework constants (derived from phases.json / trust-manager.js) ──

PHASE_POSITIONS = {
    'proposal': 1,
    'uiux': 2, 'design': 2,
    'plan': 3,
    'tasks': 4,
    'implement': 5, 'sync': 5,
}
PHASE_LABELS = {
    'proposal': 'proposal',
    'uiux': 'UI/UX',
    'design': 'design',
    'plan': 'planning',
    'tasks': 'tasks',
    'implement': 'implement',
    'sync': 'sync',
}
PIPELINE_TOTAL = 5

# Approval gates per phase (from phases.json pausePoints)
PHASE_GATES = {
    'proposal': 'proposal',
    'uiux':     'uiux',
    'design':   'design',
    'tasks':    'tasks',
}

# Trust level thresholds and badges (from trust-manager.js)
# passRate >= threshold → level
TRUST_LEVELS = [
    (0.95, 'maximum', GREEN + BOLD, '◆◆◆◆'),
    (0.90, 'high',    GREEN,        '◆◆◆○'),
    (0.80, 'medium',  YELLOW,       '◆◆○○'),
    (0.00, 'low',     RED,          '◆○○○'),
]

CHECKPOINT_FREQUENCY = 3  # matches llm-interaction.json default


# ── General helpers ──────────────────────────────────────────────────────────

def ctx_color(pct):
    """Color based on context usage. Thresholds from Claude Code docs: 70% / 90%."""
    if pct < 70:
        return GREEN
    if pct < 90:
        return YELLOW
    return RED


def progress_bar(pct, width=8):
    filled = int(pct / 100 * width)
    empty = width - filled
    return f"{'█' * filled}{'░' * empty}"


def format_tokens(n):
    if n >= 1_000_000:
        return f"{n / 1_000_000:.1f}m"
    if n >= 1000:
        return f"{n // 1000}k"
    return str(n)


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


# ── MORPH feature helpers ────────────────────────────────────────────────────

def calculate_trust(checkpoints):
    """Return (level_str, color, pass_rate) from checkpoint array."""
    if not checkpoints:
        return 'none', GRAY, None  # no data — silent
    total = len(checkpoints)
    passed = sum(1 for c in checkpoints if c.get('passed'))
    rate = passed / total
    for threshold, level, color, _badge in TRUST_LEVELS:
        if rate >= threshold:
            return level, color, rate
    return 'low', RED, 0.0


def get_checkpoint_countdown(tasks_done):
    """Tasks remaining until next checkpoint fires (frequency=3)."""
    if tasks_done <= 0:
        return None
    remaining = CHECKPOINT_FREQUENCY - (tasks_done % CHECKPOINT_FREQUENCY)
    return 0 if remaining == CHECKPOINT_FREQUENCY else remaining


def get_session_feature_names(features_dict, entries):
    """Return only feature names mentioned in the current session's transcript.
    If a feature name appears in the transcript (tool calls, messages, results),
    this session is actively working on it. Prevents showing features from other sessions.
    """
    if not features_dict or not entries:
        return set()
    # Serialize last 500 entries for search (avoids cost on long sessions)
    recent = entries[-500:] if len(entries) > 500 else entries
    transcript_text = json.dumps(recent, ensure_ascii=False)
    return {name for name in features_dict if name in transcript_text}


def get_all_active_features(cwd, entries):
    """Return in_progress features that are active in the current session.

    Caller is responsible for only calling this when inside a secondary worktree.
    """
    state_path = Path(cwd) / '.morph' / 'state.json'
    if not state_path.exists():
        return []
    try:
        state = json.loads(state_path.read_text(encoding='utf-8'))
        features = state.get('features', {})

        # Filter to features belonging to this session (mentioned in transcript)
        session_names = get_session_feature_names(features, entries)

        if not session_names:
            return []

        result = []
        for name, feat in features.items():
            if feat.get('status') != 'in_progress':
                continue
            if name not in session_names:
                continue
            phase    = feat.get('phase', '?')
            tasks    = feat.get('tasks') or {}
            done     = safe_int(tasks.get('completed'))
            total    = safe_int(tasks.get('total'))
            gates    = feat.get('approvalGates') or {}
            checkpts = feat.get('checkpoints') or []

            pending   = [g for g, v in gates.items() if not v.get('approved')]
            trust_lvl, trust_color, trust_rate = calculate_trust(checkpts)
            countdown = get_checkpoint_countdown(done)

            result.append({
                'name':        name,
                'phase':       phase,
                'tasks_done':  done,
                'tasks_total': total,
                'pending':     pending[0] if pending else None,
                'trust_level': trust_lvl,
                'trust_color': trust_color,
                'countdown':   countdown,
            })
        return result
    except Exception:
        return []


# ── Activity log helpers ──────────────────────────────────────────────────────

def get_activity_info(cwd):
    """Read .morph/logs/activity.json and return compact summary dict.
    Returns None if file is missing or unreadable.
    """
    activity_path = Path(cwd) / '.morph' / 'logs' / 'activity.json'
    if not activity_path.exists():
        return None
    try:
        data = json.loads(activity_path.read_text(encoding='utf-8'))
        hooks = data.get('hooks') or []
        skills = data.get('skills') or []

        last_hook = None
        last_hook_age = None
        if hooks:
            last = hooks[-1]
            last_hook = last.get('name', '')
            # Try to compute age from timestamp (HH:MM:SS)
            try:
                ts = last.get('ts', '')
                if ts and len(ts) == 8:
                    now = datetime.now()
                    hook_t = datetime.strptime(ts, '%H:%M:%S').replace(
                        year=now.year, month=now.month, day=now.day
                    )
                    diff_s = (now - hook_t).total_seconds()
                    if diff_s < 0:
                        diff_s = 0
                    if diff_s < 60:
                        last_hook_age = f"{int(diff_s)}s"
                    else:
                        last_hook_age = f"{int(diff_s // 60)}m"
            except Exception:
                pass

        return {
            'hook_count': len(hooks),
            'skill_count': len(skills),
            'last_hook': last_hook,
            'last_hook_age': last_hook_age,
        }
    except Exception:
        return None


# ── 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().strip()


def get_git_info(cwd):
    """Get git branch, files changed, line diff, and ahead/behind remote."""
    try:
        branch = _run_git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd)

        # Files with changes (staged + unstaged, no untracked)
        files = 0
        try:
            status_out = _run_git(['status', '--porcelain', '--untracked-files=no'], cwd)
            files = len([l for l in status_out.splitlines() if l.strip()])
        except Exception:
            pass

        # Lines inserted/removed vs HEAD
        ins, dels = 0, 0
        try:
            out = _run_git(['diff', 'HEAD', '--shortstat'], cwd)
            m = re.search(r'(\d+) insertion', out)
            if m:
                ins = int(m.group(1))
            m = re.search(r'(\d+) deletion', out)
            if m:
                dels = int(m.group(1))
        except Exception:
            pass

        # Commits ahead/behind remote
        ahead, behind = 0, 0
        try:
            ab = _run_git(['rev-list', '--left-right', '--count', 'HEAD...@{upstream}'], cwd)
            ahead, behind = (int(x) for x in ab.split('\t'))
        except Exception:
            pass

        parts = [f"{BLUE} {branch}{R}"]
        if ahead or behind:
            ab_str = ''
            if ahead:  ab_str += f"{GREEN}↑{ahead}{R}"
            if behind: ab_str += f"{RED}↓{behind}{R}"
            parts.append(ab_str)
        if files:
            parts.append(f"{GRAY}{files} files{R}")
        if ins or dels:
            parts.append(f"{GREEN}+{ins}{R}{GRAY},{R}{RED}-{dels}{R}")

        return ' '.join(parts)
    except Exception:
        return ""


def get_worktree_data(cwd):
    """Detect worktree status for cwd.

    Returns (is_secondary: bool, display_str: str).
    is_secondary is True only when cwd is a non-primary git worktree.
    display_str is the formatted label (non-empty only when is_secondary).
    Called once in main() to avoid duplicate git subprocess.
    """
    try:
        out = _run_git(['worktree', 'list', '--porcelain'], cwd)
        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:
            cwd_r = str(Path(cwd).resolve())
            for entry in entries[1:]:
                if str(Path(entry.get('path', '')).resolve()) == cwd_r:
                    branch = entry.get('branch', '').replace('refs/heads/', '')
                    return True, f"{MAGENTA}worktree:{branch}{R}"
    except Exception:
        pass
    return False, ""


def get_recent_tool_calls(entries):
    """Scan the last 50 transcript entries for the most recent Skill and Agent tool calls.

    Returns (last_skill_name, last_agent_name) — either may be None.
    Looks at assistant messages containing tool_use blocks.
    """
    last_skill = None
    last_agent = None
    recent = entries[-50:] if len(entries) > 50 else entries
    for entry in reversed(recent):
        if entry.get('isSidechain') or entry.get('isApiErrorMessage'):
            continue
        msg = entry.get('message') or {}
        content = msg.get('content') or []
        if not isinstance(content, list):
            continue
        # Within each entry, scan content from last to first to get most-recent call
        for item in reversed(content):
            if not isinstance(item, dict) or item.get('type') != 'tool_use':
                continue
            tool = item.get('name', '')
            inp = item.get('input') or {}
            if tool == 'Skill' and last_skill is None:
                last_skill = inp.get('skill') or ''
            elif tool == 'Agent' and last_agent is None:
                agent_name = inp.get('subagent_type') or ''
                if not agent_name:
                    desc = inp.get('description') or inp.get('prompt') or ''
                    agent_name = (desc[:22] + '…') if len(desc) > 25 else desc
                last_agent = agent_name
        if last_skill is not None and last_agent is not None:
            break
    return last_skill or None, last_agent or None


# ── Transcript / JSONL helpers ────────────────────────────────────────────────

def read_transcript_jsonl(path):
    """Read and parse JSONL transcript file. Returns list of parsed entries."""
    entries = []
    try:
        with open(path, 'r', encoding='utf-8') as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                try:
                    entries.append(json.loads(line))
                except Exception:
                    pass
    except Exception:
        pass
    return entries


def get_token_metrics(entries):
    """Sum token usage from all non-sidechain JSONL entries."""
    total_input, total_output, total_cached = 0, 0, 0
    for entry in entries:
        if entry.get('isSidechain') or entry.get('isApiErrorMessage'):
            continue
        usage = (entry.get('message') or {}).get('usage') or {}
        if not usage:
            continue
        total_input  += usage.get('input_tokens', 0)
        total_output += usage.get('output_tokens', 0)
        total_cached += (
            usage.get('cache_creation_input_tokens', 0) +
            usage.get('cache_read_input_tokens', 0)
        )
    return {'input': total_input, 'output': total_output, 'cached': total_cached}


def parse_timestamp(ts):
    """Parse ISO 8601 timestamp to Unix float. Returns None on error."""
    try:
        return datetime.fromisoformat(ts.replace('Z', '+00:00')).timestamp()
    except Exception:
        return None


def get_session_start(cwd, transcript_path, entries):
    """Return Unix timestamp for the start of the current session.

    Uses two caches:
    - sess-tp-*  : keyed by transcript_path — persists the start for this
                   session even after long absences (wall-clock elapsed)
    - sess-cwd-* : keyed by cwd, 2-min window — detects plan→accept
                   (new transcript created seconds after the previous one)

    Each Claude Code window gets its own timer (per transcript).
    """
    if os.name == 'nt':
        base = Path(os.environ.get('LOCALAPPDATA', str(Path.home() / 'AppData' / 'Local')))
        cache_dir = base / 'morph-spec' / 'cache'
    else:
        cache_dir = Path.home() / '.cache' / 'morph-spec'

    now = time.time()

    if transcript_path:
        tp_h     = hashlib.sha256(transcript_path.encode()).hexdigest()[:16]
        tp_cache = cache_dir / f'sess-tp-{tp_h}.json'
    else:
        tp_cache = None

    cwd_h     = hashlib.sha256(cwd.encode()).hexdigest()[:16]
    cwd_cache = cache_dir / f'sess-cwd-{cwd_h}.json'

    # 1. This transcript already has a recorded start?
    session_start = None
    try:
        if tp_cache and tp_cache.exists():
            session_start = json.loads(tp_cache.read_text()).get('session_start')
    except Exception:
        pass

    # 2. New transcript — continuation from plan→accept? (cwd gap < 2 min)
    if session_start is None:
        try:
            if cwd_cache.exists():
                cwd_data = json.loads(cwd_cache.read_text())
                if (now - cwd_data.get('last_seen', 0)) < 120:
                    session_start = cwd_data.get('session_start')
        except Exception:
            pass

    # 3. Genuinely new session — use first entry from current transcript
    if session_start is None:
        for entry in entries:
            ts = entry.get('timestamp')
            if not ts:
                continue
            t = parse_timestamp(ts)
            if t is not None:
                session_start = t
                break

    if session_start is None:
        return None

    # Persist both caches
    try:
        cache_dir.mkdir(parents=True, exist_ok=True)
        payload = json.dumps({'session_start': session_start, 'last_seen': now})
        if tp_cache:
            tp_cache.write_text(payload)
        cwd_cache.write_text(payload)
    except Exception:
        pass

    return session_start


def get_session_duration(session_start):
    """Format elapsed time since session_start. Returns string or None."""
    if session_start is None:
        return None
    elapsed_s = time.time() - session_start
    hours   = int(elapsed_s // 3600)
    minutes = int((elapsed_s % 3600) // 60)
    if hours == 0:
        return f"{minutes}m"
    elif minutes == 0:
        return f"{hours}hr"
    else:
        return f"{hours}hr {minutes}m"


def get_session_name(entries):
    """Find the most recent /rename title. Returns string or None."""
    for entry in reversed(entries):
        if entry.get('type') == 'custom-title' and entry.get('customTitle'):
            return entry['customTitle']
    return None



# ── Main ──────────────────────────────────────────────────────────────────────

def main():
    try:
        raw = sys.stdin.read()
        if not raw.strip():
            sys.exit(0)
        data = json.loads(raw)
    except Exception:
        sys.exit(0)

    try:
        cwd             = data.get('cwd', os.getcwd())
        transcript_path = data.get('transcript_path')

        # Read JSONL transcript once — shared by session clock, token metrics,
        # session name, and skill/agent detection.
        entries = read_transcript_jsonl(transcript_path) if transcript_path else []

        # ── Worktree detection (single git call) ─────────────────────────────────
        # Feature lines are only shown when inside a secondary git worktree.
        # This prevents stale feature names showing up during unrelated work
        # in the main worktree.
        is_worktree, wt_display = get_worktree_data(cwd)

        # ── MORPH feature lines (one line per active feature) ────────────────────
        features = get_all_active_features(cwd, entries) if is_worktree else []
        for feat in features:
            # Feature name with visual prefix
            parts = [f"{CYAN}{BOLD}► {feat['name']}{R}"]

            # Phase: human-readable label + position in pipeline (e.g. "implement 4/5")
            phase = feat['phase']
            pos   = PHASE_POSITIONS.get(phase)
            label = PHASE_LABELS.get(phase, phase)
            if pos:
                parts.append(f"{CYAN}{label} {pos}/{PIPELINE_TOTAL}{R}")
            elif phase != '?':
                parts.append(f"{CYAN}{label}{R}")

            # Task progress (only if tasks exist)
            if feat['tasks_total'] > 0:
                pct = feat['tasks_done'] / feat['tasks_total'] * 100
                bar = progress_bar(pct, 6)
                parts.append(f"{GREEN}{bar} {feat['tasks_done']}/{feat['tasks_total']} tasks{R}")

            # Checkpoint: only show when imminent (0 = now, 1 = next task)
            if feat['countdown'] == 0:
                parts.append(f"{GREEN}💾 checkpoint!{R}")
            elif feat['countdown'] == 1:
                parts.append(f"{YELLOW}💾 in 1 task{R}")

            # Trust: silent when good, alert only if problematic
            if feat['trust_level'] == 'low':
                parts.append(f"{RED}⚠ low trust{R}")
            elif feat['trust_level'] == 'medium':
                parts.append(f"{YELLOW}⚠ medium trust{R}")

            # Blocking approval gate
            if feat['pending']:
                parts.append(f"{RED}⛔ approval required{R}")

            print('  |  '.join(parts))

        # ── Activity info line (hooks; only shown when a feature is active) ───────
        if features:
            activity = get_activity_info(cwd)
            if activity and activity['hook_count'] > 0:
                hook_label = activity['last_hook'] or '?'
                age_str = f"({activity['last_hook_age']})" if activity['last_hook_age'] else ''
                hook_str = f"{BLUE}🪝 {hook_label} {age_str}{R}".strip()
                print(f"  {GRAY}└{R} {hook_str}")

        # ── Session info line (always shown) ─────────────────────────────────────
        parts2 = []

        # Session name (set via /rename)
        if entries:
            session_name = get_session_name(entries)
            if session_name:
                parts2.append(f"{CYAN}{BOLD}📌 {session_name}{R}")

        # Model
        model = data.get('model') or ''
        if isinstance(model, dict):
            model_name = model.get('display_name') or model.get('id') or ''
        else:
            model_name = str(model) if model else ''
        if model_name:
            short = model_name.replace('Claude ', '').replace('claude-', '').replace(' (claude.ai)', '')
            parts2.append(f"{WHITE}{BOLD}🤖 {short}{R}")

        # Permission mode (only shown when non-default)
        perm = data.get('permission_mode') or ''
        if perm and perm != 'default':
            _perm_labels = {
                'plan':               '📋 plan',
                'acceptEdits':        '✏ accept edits',
                'bypassPermissions':  '⚠ bypass',
                'dontAsk':            '🔕 auto',
            }
            parts2.append(f"{YELLOW}{_perm_labels.get(perm, perm)}{R}")

        # Last skill and agent invoked (parsed from transcript tool_use blocks)
        if entries:
            last_skill, last_agent = get_recent_tool_calls(entries)
            if last_skill:
                parts2.append(f"{YELLOW}🎯 {last_skill}{R}")
            if last_agent:
                parts2.append(f"{MAGENTA}⚡ {last_agent}{R}")

        # Session clock (elapsed time since session start, survives transcript transitions)
        session_start = get_session_start(cwd, transcript_path, entries)
        duration = get_session_duration(session_start)
        if duration:
            parts2.append(f"{YELLOW}⏱ {duration}{R}")

        # Context window
        # used_percentage excludes output_tokens — underestimates real usage in long sessions.
        # Compute (input + cache_creation + cache_read + output) / context_window_size,
        # which reflects true context consumption for the next turn.
        ctx = data.get('context_window', {})
        if ctx:
            cur_usage = ctx.get('current_usage') or {}
            total_ctx = safe_int(ctx.get('context_window_size'))
            if isinstance(cur_usage, dict) and total_ctx > 0:
                tokens = (safe_int(cur_usage.get('input_tokens'))
                        + safe_int(cur_usage.get('cache_creation_input_tokens'))
                        + safe_int(cur_usage.get('cache_read_input_tokens'))
                        + safe_int(cur_usage.get('output_tokens')))
                used_pct = tokens / total_ctx * 100 if tokens > 0 else safe_float(ctx.get('used_percentage'))
            else:
                used_pct = safe_float(ctx.get('used_percentage'))
            color  = ctx_color(used_pct)
            bar    = progress_bar(used_pct, 8)
            toks   = f"{format_tokens(tokens)}/{format_tokens(total_ctx)}" if total_ctx and isinstance(cur_usage, dict) else ""
            suffix = f" {RED}~cmpct{R}" if used_pct >= 90 else ""
            line   = f"{color}{bar} {used_pct:.0f}%{R}"
            if toks:
                line += f" ({toks})"
            parts2.append(line + suffix)

        # Git info (branch + diff stats)
        git = get_git_info(cwd)
        if git:
            parts2.append(git)

        # Worktree label (already computed above — reuse result)
        if wt_display:
            parts2.append(wt_display)

        if parts2:
            print('  |  '.join(parts2))

    except Exception:
        err = traceback.format_exc().splitlines()[-1]
        print(f"{RED}ERRO: {err}{R}")


if __name__ == '__main__':
    main()
