"""Training system — captures skills/spells from MUD tables, auto-trains.

Supports multiple guilds per reinc. Each guild's skills/spells are captured
and stored separately. Reinc configuration (guilds, levels, navigation)
is persisted to reinc.json; training data to state.json.
"""

import dataclasses
import json
import math
import os
import re

try:
    import tf
except ImportError:
    from py import tf  # Use stub when testing outside TF


@dataclasses.dataclass
class Entry:
    name: str
    type: str           # 'spell' or 'skill'
    current_pct: int
    max_pct: int
    target_pct: int     # defaults to max_pct, overridable
    reps: int           # (target - cur) / 5
    cost: int           # exp per next 5% (from table Exp column)
    money_cost: int     # money per next 5% (from table $ column)
    excluded: bool      # in exclude set
    cost_schedule: dict = dataclasses.field(default_factory=dict)
    # Maps pct (int) -> (exp_cost, money_cost) for each 5% step
    # e.g. {5: (17500, 7), 10: (35000, 14), ...}
    guild: str = ''     # guild id this entry was captured from
    priority: int = 0   # hard priority — higher trains first regardless of cost


@dataclasses.dataclass
class GuildConfig:
    name: str           # full guild name, e.g. 'rangers of the forgotten deserts'
    id: str             # short id for commands, e.g. 'rotfd'
    levels: int         # current guild levels
    to_macro: str       # TF macro for navigation to trainer
    from_macro: str     # TF macro for return navigation


# --- Module state ---

_base_dir = None
_spells = {}            # guild_id -> {name -> Entry}
_skills = {}            # guild_id -> {name -> Entry}
_excludes = set()       # excluded names (global across guilds)
_priorities = {}        # name -> int priority (global across guilds)
_soft_order = {}        # name -> int position from order.txt
_active_guild = None    # guild id for captures
_reinc = {}             # guild_id -> GuildConfig
_capture_mode = None    # 'spells', 'skills', or None
_capture_next = None    # for chaining spells -> skills
_current_exp = 0        # set externally via set_exp()
_estimate_full = False  # if True, re-estimate even entries with schedules
_capture_quick = False  # if True, preserve cost_schedule and skip estimates
_auto_advance = False   # if True, auto-advance to next guild in init sequence
_estimate_queue = []    # list of Entry objects to estimate
_estimate_current = None  # entry currently being estimated
_estimate_data = {}     # pct -> (exp, money) being built
_estimate_timer_id = 0  # counter for estimate watchdog timers
_training_entry = None  # Entry currently being trained by train_next
_init_queue = []        # guild ids remaining for reinc init
_init_current = None    # guild id currently being captured during init


# --- Helpers ---

def _all_entries():
    """Return all Entry objects across all guilds."""
    result = []
    for guild_data in _spells.values():
        result.extend(guild_data.values())
    for guild_data in _skills.values():
        result.extend(guild_data.values())
    return result


def _unique_entries():
    """Return deduplicated entries across guilds — cheapest guild wins per name."""
    best = {}  # name -> Entry
    for entry in _all_entries():
        prev = best.get(entry.name)
        if prev is None or entry.cost < prev.cost:
            best[entry.name] = entry
    return list(best.values())


def _find_all_copies(name):
    """Find all copies of an entry across guilds."""
    copies = []
    for guild_data in list(_spells.values()) + list(_skills.values()):
        if name in guild_data:
            copies.append(guild_data[name])
    return copies


def _sync_entry(entry):
    """Sync current_pct and reps to all copies of this entry across guilds."""
    for copy in _find_all_copies(entry.name):
        if copy is not entry:
            copy.current_pct = entry.current_pct
            copy.reps = max(0, (copy.target_pct - copy.current_pct) // 5)


def _guild_entries(guild_id):
    """Return all entries for a specific guild."""
    result = []
    result.extend(_spells.get(guild_id, {}).values())
    result.extend(_skills.get(guild_id, {}).values())
    return result


def _guild_ids():
    """Return set of all guild ids that have captured data."""
    return set(_spells.keys()) | set(_skills.keys())


# --- Persistence: training state ---

def _save_path():
    return os.path.join(_base_dir, 'trainlib', 'state.json') if _base_dir else None


def _entry_dict(e):
    d = dataclasses.asdict(e)
    # Convert cost_schedule int keys to strings for JSON
    d['cost_schedule'] = {str(k): v for k, v in e.cost_schedule.items()}
    return d


def _write_state(path):
    """Write current state to path."""
    guilds_data = {}
    for gid in _guild_ids():
        guilds_data[gid] = {
            'spells': {n: _entry_dict(e) for n, e in _spells.get(gid, {}).items()},
            'skills': {n: _entry_dict(e) for n, e in _skills.get(gid, {}).items()},
        }
    data = {
        'excludes': sorted(_excludes),
        'priorities': {k: v for k, v in sorted(_priorities.items())},
        'active_guild': _active_guild,
        'current_exp': _current_exp,
        'guilds': guilds_data,
    }
    with open(path, 'w') as f:
        json.dump(data, f, indent=1)


def _autosave():
    """Save silently after mutations."""
    path = _save_path()
    if path:
        _write_state(path)


def save():
    """Persist captured data and excludes to disk."""
    path = _save_path()
    if not path:
        return
    _write_state(path)
    total_spells = sum(len(d) for d in _spells.values())
    total_skills = sum(len(d) for d in _skills.values())
    _echo(f'>>> Saved training state ({total_spells} spells, {total_skills} skills, '
          f'{len(_excludes)} excludes, {len(_guild_ids())} guilds)', 'green')


def _entry_from_dict(d):
    """Create Entry from dict, converting cost_schedule keys to int."""
    d = dict(d)
    cs = d.get('cost_schedule', {})
    d['cost_schedule'] = {int(k): tuple(v) for k, v in cs.items()}
    # Handle old state files without guild/priority fields
    d.setdefault('guild', '')
    d.setdefault('priority', 0)
    return Entry(**d)


def _load_state_data(data):
    """Populate module state from parsed JSON data. Handles old and new formats."""
    global _spells, _skills, _excludes, _priorities, _active_guild, _current_exp
    _excludes = set(data.get('excludes', []))
    _priorities = {k: int(v) for k, v in data.get('priorities', {}).items()}
    _current_exp = int(data.get('current_exp', 0))

    if 'guilds' in data:
        # New per-guild format
        _active_guild = data.get('active_guild')
        _spells = {}
        _skills = {}
        for gid, gdata in data['guilds'].items():
            _spells[gid] = {n: _entry_from_dict(d) for n, d in gdata.get('spells', {}).items()}
            _skills[gid] = {n: _entry_from_dict(d) for n, d in gdata.get('skills', {}).items()}
    else:
        # Old flat format — migrate under 'default' guild
        gid = 'default'
        _spells = {gid: {n: _entry_from_dict(d) for n, d in data.get('spells', {}).items()}}
        _skills = {gid: {n: _entry_from_dict(d) for n, d in data.get('skills', {}).items()}}
        _active_guild = gid
        _echo('>>> Migrated old state format under "default" guild', 'yellow')


def load():
    """Load persisted state from disk."""
    path = _save_path()
    if not path or not os.path.exists(path):
        _echo('>>> No saved state found', 'yellow')
        return
    with open(path) as f:
        data = json.load(f)
    _load_state_data(data)
    total_spells = sum(len(d) for d in _spells.values())
    total_skills = sum(len(d) for d in _skills.values())
    _echo(f'>>> Loaded training state ({total_spells} spells, {total_skills} skills, '
          f'{len(_excludes)} excludes, {len(_guild_ids())} guilds)', 'green')


def _autoload():
    """Load saved state silently on startup."""
    path = _save_path()
    if not path or not os.path.exists(path):
        return
    with open(path) as f:
        data = json.load(f)
    _load_state_data(data)
    total_spells = sum(len(d) for d in _spells.values())
    total_skills = sum(len(d) for d in _skills.values())
    exp_info = f', exp: {_format_exp(_current_exp)}' if _current_exp > 0 else ''
    _echo(f'>>> Restored {total_spells} spells, {total_skills} skills, '
          f'{len(_excludes)} excludes, {len(_guild_ids())} guilds{exp_info}', 'green')


# --- Persistence: reinc config ---

def _reinc_path():
    return os.path.join(_base_dir, 'trainlib', 'reinc.json') if _base_dir else None


def _save_reinc():
    path = _reinc_path()
    if not path:
        return
    data = {
        'guilds': [dataclasses.asdict(gc) for gc in _reinc.values()],
    }
    with open(path, 'w') as f:
        json.dump(data, f, indent=1)


def _load_reinc():
    path = _reinc_path()
    if not path or not os.path.exists(path):
        return
    global _reinc
    with open(path) as f:
        data = json.load(f)
    _reinc = {}
    for gd in data.get('guilds', []):
        gc = GuildConfig(**gd)
        _reinc[gc.id] = gc
    if _reinc:
        _echo(f'>>> Loaded reinc config: {", ".join(f"{gc.id} ({gc.levels})" for gc in _reinc.values())}', 'green')


def reinc_set(guild_id, levels, name=None):
    """Add or update a guild in the reinc config."""
    levels = int(levels)
    if name is None:
        # Check if updating existing
        if guild_id in _reinc:
            _reinc[guild_id].levels = levels
        else:
            name = guild_id
            _reinc[guild_id] = GuildConfig(
                name=name, id=guild_id, levels=levels,
                to_macro=guild_id, from_macro=f'{guild_id}_cs',
            )
    else:
        if guild_id in _reinc:
            _reinc[guild_id].name = name
            _reinc[guild_id].levels = levels
        else:
            _reinc[guild_id] = GuildConfig(
                name=name, id=guild_id, levels=levels,
                to_macro=guild_id, from_macro=f'{guild_id}_cs',
            )
    _save_reinc()
    gc = _reinc[guild_id]
    _echo(f'>>> Reinc: {gc.id} = {gc.name} ({gc.levels} levels, '
          f'macros: {gc.to_macro}/{gc.from_macro})', 'green')


def reinc_show():
    """Display reinc guild configuration."""
    if not _reinc:
        _echo('>>> No reinc config. Use: /training reinc set <id> <levels> [name]', 'yellow')
        return
    _echo('>>> Reinc configuration:', 'green')
    for gc in _reinc.values():
        _echo(f'>>>   {gc.id}: {gc.name} ({gc.levels} levels, '
              f'macros: {gc.to_macro}/{gc.from_macro})')


def reinc_clear():
    """Remove all guild configs."""
    global _reinc
    _reinc = {}
    _save_reinc()
    _echo('>>> Reinc config cleared', 'green')


# --- Soft priority (order file) ---

def _order_path():
    return os.path.join(_base_dir, 'trainlib', 'order.txt') if _base_dir else None


def _load_soft_order():
    """Load order.txt into _soft_order dict (name -> line index)."""
    global _soft_order
    _soft_order = {}
    path = _order_path()
    if not path or not os.path.exists(path):
        return
    with open(path) as f:
        for i, line in enumerate(f):
            name = line.strip()
            if name and not name.startswith('#'):
                _soft_order[name] = i


def dump_order():
    """Write all unique entry names to order.txt sorted by (cost, name)."""
    path = _order_path()
    if not path:
        _echo('>>> No base dir set', 'red')
        return
    entries = _unique_entries()
    entries.sort(key=lambda e: (e.cost, e.name))
    names = []
    seen = set()
    for e in entries:
        if e.name not in seen:
            names.append(e.name)
            seen.add(e.name)
    with open(path, 'w') as f:
        f.write('# Soft priority order — skills earlier in file train first among equal cost\n')
        f.write('# Edit manually to reorder. Reload with /training reload\n')
        for name in names:
            f.write(name + '\n')
    _load_soft_order()
    _echo(f'>>> Wrote {len(names)} entries to {path}', 'green')
    _echo('>>> Edit the file to reorder same-cost skills, then /training reload', 'green')


# --- Sort keys ---

def _sort_key(entry):
    """Sort key for entry-level sorting: priority desc, cost asc, soft order, name."""
    return (-entry.priority, entry.cost, _soft_order.get(entry.name, 999999), entry.name)


def _step_sort_key(step):
    """Sort key for plan steps: (exp_cost, money_cost, entry, pct)."""
    exp_cost, money_cost, entry, pct = step
    return (-entry.priority, exp_cost, _soft_order.get(entry.name, 999999), entry.name)


# --- Init ---

def init(base_dir):
    global _base_dir, _current_exp
    _base_dir = base_dir
    _load_reinc()
    _autoload()
    _load_soft_order()
    try:
        import exptrack
        exptrack.on_change('exp', lambda new, old: update_exp(new))
        # Seed from exptrack if it already has a value (e.g. after /training reload
        # which reloads trainer but not exptrack)
        if exptrack.exp > 0 and _current_exp == 0:
            _current_exp = exptrack.exp
    except ImportError:
        pass  # exptrack not loaded, exp tracking via manual /training check


def _echo(msg, color=None):
    if color:
        tf.eval(f'/echo -aC{color} {msg}')
    else:
        tf.eval(f'/echo {msg}')


def _send(cmd):
    tf.eval(f'!{cmd}')


def _parse_exp(s):
    """Parse exp strings like '125', '303k', '5.4M' -> int."""
    s = s.strip()
    if not s:
        return 0
    m = re.match(r'^([0-9.]+)([kKmM]?)$', s)
    if not m:
        return 0
    num = float(m.group(1))
    suffix = m.group(2).upper()
    if suffix == 'K':
        return int(num * 1000)
    elif suffix == 'M':
        return int(num * 1000000)
    else:
        return int(num)


def _format_exp(amount):
    """Format exp as human-readable k/M notation, ceiling to 2 decimals."""
    if amount >= 1000000:
        val = math.ceil(amount / 10000) / 100
        return f'{val:.2f}M'
    else:
        val = math.ceil(amount / 10) / 100
        if val == int(val):
            return f'{int(val)}k'
        return f'{val:g}k'


def _find_entry(name):
    """Find an entry by name across all guilds (spells and skills)."""
    for guild_data in _spells.values():
        if name in guild_data:
            return guild_data[name]
    for guild_data in _skills.values():
        if name in guild_data:
            return guild_data[name]
    return None


def _total_remaining_cost(entry):
    """Compute total exp cost from current_pct to target_pct using cost_schedule."""
    if not entry.cost_schedule or entry.reps <= 0:
        return None
    total = 0
    pct = entry.current_pct + 5
    while pct <= entry.target_pct and pct in entry.cost_schedule:
        exp_cost, _ = entry.cost_schedule[pct]
        total += exp_cost
        pct += 5
    return total if total > 0 else None


# --- Reinc init wizard ---

def init_start():
    """Clear data and set up guild init sequence."""
    global _init_queue, _init_current, _spells, _skills, _excludes, _active_guild
    if not _reinc:
        _echo('>>> No reinc config. Set up guilds first with /training reinc set', 'red')
        return
    _spells = {}
    _skills = {}
    _excludes = set()
    _active_guild = None
    _init_queue = list(_reinc.keys())
    _init_current = None
    _autosave()
    guild_list = ', '.join(_init_queue)
    _echo(f'>>> Init started — {len(_init_queue)} guilds to capture: {guild_list}', 'green')
    _echo('>>> Use /training reinc init_next to begin (starts at cs)', 'green')


def init_next():
    """Execute next step in reinc init: travel to guild, capture, travel back."""
    global _init_current, _active_guild
    if _init_current is not None:
        _echo(f'>>> Capture in progress for {_init_current}. Please wait.', 'yellow')
        return
    if not _init_queue:
        _echo('>>> No init in progress. Use /training reinc init_start', 'yellow')
        return
    guild_id = _init_queue.pop(0)
    gc = _reinc.get(guild_id)
    if not gc:
        _echo(f'>>> Guild {guild_id} not in reinc config, skipping', 'red')
        if _init_queue:
            init_next()
        return
    _init_current = guild_id
    _active_guild = guild_id
    remaining = len(_init_queue)
    _echo(f'>>> [{remaining + 1} left] Traveling to {gc.name}...', 'green')
    # Queue: walk to guild, then capture
    tf.eval(f'/{gc.to_macro}')
    capture_all()


def capture_all_guilds(estimate=False):
    """Recapture all guilds. Quick mode preserves cost_schedule, estimate mode re-fetches."""
    global _init_queue, _init_current, _capture_quick, _auto_advance
    if not _reinc:
        _echo('>>> No reinc config. Set up guilds first with /training reinc set', 'red')
        return
    _init_queue = list(_reinc.keys())
    _init_current = None
    _capture_quick = not estimate
    _auto_advance = True
    guild_list = ', '.join(_init_queue)
    mode = 'with estimates' if estimate else 'quick'
    _echo(f'>>> Recapture ({mode}) — {len(_init_queue)} guilds: {guild_list}', 'green')
    init_next()


def _init_on_capture_complete():
    """Called when capture+estimates finish during init. Travels back, advances."""
    global _init_current, _capture_quick, _auto_advance
    if _init_current is None:
        return
    guild_id = _init_current
    _init_current = None
    gc = _reinc.get(guild_id)
    if gc:
        tf.eval(f'/{gc.from_macro}')
    if _init_queue:
        if _auto_advance:
            # Auto-advance: queue next guild after walk-back commands
            tf.eval('/repeat -1 1 /python _trainer.init_next()')
        else:
            remaining = len(_init_queue)
            tf.eval(f'/repeat -1 1 /echo -aCgreen >>> {remaining} guild(s) remaining. Use /training reinc init_next')
    else:
        _capture_quick = False
        _auto_advance = False
        tf.eval('/repeat -1 1 /echo -aCgreen >>> Recapture complete — all guilds updated!')
    _autosave()


# --- Active guild ---

def set_active_guild(guild_id):
    global _active_guild
    guild_id = guild_id.strip()
    _active_guild = guild_id
    gc = _reinc.get(guild_id)
    if gc:
        _echo(f'>>> Active guild: {gc.id} ({gc.name}, {gc.levels} levels)', 'green')
    else:
        _echo(f'>>> Active guild: {guild_id} (not in reinc config)', 'yellow')
    _autosave()


def show_active_guild():
    if _active_guild:
        gc = _reinc.get(_active_guild)
        if gc:
            _echo(f'>>> Active guild: {gc.id} ({gc.name}, {gc.levels} levels)', 'green')
        else:
            _echo(f'>>> Active guild: {_active_guild}', 'green')
    else:
        _echo('>>> No active guild set. Use: /training guild <id>', 'yellow')


# --- Navigation ---

def visit(guild_id):
    """Navigate to guild trainer and set active guild."""
    global _active_guild
    guild_id = guild_id.strip()
    gc = _reinc.get(guild_id)
    if not gc:
        _echo(f'>>> Unknown guild: {guild_id}. Use /training reinc show', 'red')
        return
    _active_guild = guild_id
    _echo(f'>>> Navigating to {gc.name} trainer...', 'green')
    tf.eval(f'/{gc.to_macro}')
    _autosave()


def visit_cs(guild_id=None):
    """Return from guild trainer."""
    gid = guild_id.strip() if guild_id else _active_guild
    if not gid:
        _echo('>>> No guild specified and no active guild set', 'red')
        return
    gc = _reinc.get(gid)
    if not gc:
        _echo(f'>>> Unknown guild: {gid}. Use /training reinc show', 'red')
        return
    _echo(f'>>> Returning from {gc.name} trainer...', 'green')
    tf.eval(f'/{gc.from_macro}')


# --- Capture from MUD table output ---

def _ensure_borg():
    """Re-enable %borg if it was turned off by trigger rate limiter."""
    tf.eval('/set borg=on')


def capture_spells(full=False):
    global _capture_mode, _capture_next, _estimate_full
    if _active_guild is None:
        _echo('>>> No active guild. Use: /training guild <id>', 'red')
        return
    _spells.setdefault(_active_guild, {}).clear()
    _capture_mode = 'spells'
    _capture_next = None
    _estimate_full = full
    _ensure_borg()
    _echo(f'>>> Capturing spells for {_active_guild}...', 'green')
    _send('list spells')


def capture_skills(full=False):
    global _capture_mode, _capture_next, _estimate_full
    if _active_guild is None:
        _echo('>>> No active guild. Use: /training guild <id>', 'red')
        return
    _skills.setdefault(_active_guild, {}).clear()
    _capture_mode = 'skills'
    _capture_next = None
    _estimate_full = full
    _ensure_borg()
    _echo(f'>>> Capturing skills for {_active_guild}...', 'green')
    _send('list skills all')


def capture_all(full=False):
    global _capture_mode, _capture_next, _estimate_full
    if _active_guild is None:
        _echo('>>> No active guild. Use: /training guild <id>', 'red')
        return
    _spells.setdefault(_active_guild, {}).clear()
    _skills.setdefault(_active_guild, {}).clear()
    _capture_mode = 'spells'
    _capture_next = 'skills'
    _estimate_full = full
    _ensure_borg()
    _echo(f'>>> Capturing spells and skills for {_active_guild}...', 'green')
    _send('list spells')


def capture_row(name, lvl, cur_pct, max_pct, exp_str, money_cost):
    """Called by TF trigger for each table data row."""
    if _capture_mode is None or _active_guild is None:
        return

    cost = _parse_exp(exp_str)
    excluded = name in _excludes
    priority = _priorities.get(name, 0)

    # Preserve custom target from previous capture if set
    target = max_pct
    if _capture_mode == 'spells':
        guild_dict = _spells.setdefault(_active_guild, {})
    else:
        guild_dict = _skills.setdefault(_active_guild, {})
    old = guild_dict.get(name)
    if old and old.target_pct != old.max_pct:
        target = min(old.target_pct, max_pct)

    reps = max(0, (target - cur_pct) // 5)

    # In quick mode, preserve cost_schedule and update cost from it
    old_schedule = {}
    if _capture_quick and old and old.cost_schedule:
        old_schedule = old.cost_schedule
        next_pct = cur_pct + 5
        if next_pct in old_schedule:
            cost, money_cost = old_schedule[next_pct]

    entry = Entry(
        name=name,
        type='spell' if _capture_mode == 'spells' else 'skill',
        current_pct=cur_pct,
        max_pct=max_pct,
        target_pct=target,
        reps=reps,
        cost=cost,
        money_cost=money_cost,
        excluded=excluded,
        cost_schedule=old_schedule,
        guild=_active_guild,
        priority=priority,
    )

    guild_dict[name] = entry


def capture_end():
    """Called by TF trigger at '| Total:' line."""
    global _capture_mode, _capture_next

    if _capture_mode is None:
        return

    if _capture_next == 'skills':
        _capture_mode = 'skills'
        _capture_next = None
        _send('list skills all')
    else:
        _capture_mode = None
        _echo('>>> Capture complete', 'green')
        if _capture_quick:
            _autosave()
            _init_on_capture_complete()
        else:
            _start_estimates()


# --- Estimate capture ---

def _start_estimates():
    """Build queue of entries needing estimates for the active guild."""
    global _estimate_queue
    _estimate_queue = []
    if _active_guild is None:
        return
    guild_entries = list(_spells.get(_active_guild, {}).values()) + \
                    list(_skills.get(_active_guild, {}).values())
    for e in guild_entries:
        if e.reps <= 0:
            continue
        if not _estimate_full and e.cost_schedule:
            continue
        _estimate_queue.append(e)
    if _estimate_queue:
        _echo(f'>>> Estimating {len(_estimate_queue)} entries...', 'green')
        # Defer first estimate — capture table's trailing backtick line
        # is still in TF's input buffer and would trigger estimate_end()
        tf.eval('/repeat -1 1 /python _trainer._next_estimate()')
    else:
        _autosave()
        status()
        _init_on_capture_complete()


def _next_estimate():
    """Send estimate for next entry in queue, or finish."""
    global _estimate_current, _estimate_data, _estimate_timer_id
    if not _estimate_queue:
        _estimate_current = None
        _autosave()
        status()
        _init_on_capture_complete()
        return
    entry = _estimate_queue.pop(0)
    _estimate_current = entry
    _estimate_data = {}
    verb = 'study' if entry.type == 'spell' else 'train'
    # Start watchdog timer — if estimate doesn't complete in 3s, skip it
    _estimate_timer_id += 1
    timer_id = _estimate_timer_id
    tf.eval(f'/repeat -1 3 /python _trainer._estimate_timeout({timer_id})')
    _send(f'estimate {verb} {entry.name}')


def _estimate_timeout(timer_id):
    """Watchdog: skip current estimate if it hasn't completed."""
    if timer_id != _estimate_timer_id:
        return  # stale timer, estimate already completed
    if _estimate_current is None:
        return  # already done
    _echo(f'>>> Estimate timeout for {_estimate_current.name}, skipping', 'yellow')
    _next_estimate()


def estimate_header(name):
    """Called by TF trigger on 'Estimate train skill: <name>.' line."""
    global _estimate_current, _estimate_data
    if _estimate_current is not None:
        # Already set by _next_estimate() — don't override with wrong guild copy
        _estimate_data = {}
        return
    # Manual estimate (not from auto-flow) — look up entry
    entry = _find_entry(name)
    if entry:
        _estimate_current = entry
        _estimate_data = {}


def estimate_row(line):
    """Called by TF trigger for estimate table data rows.

    Parses pairs like '  5:    17500 |      7' from the line.
    """
    # Match all pct: exp | money groups in the line
    for m in re.finditer(r'(\d+)\s*:\s*(\d+)\s*\|\s*(\d+)', line):
        pct = int(m.group(1))
        exp = int(m.group(2))
        money = int(m.group(3))
        _estimate_data[pct] = (exp, money)


def estimate_end():
    """Called by TF trigger on backtick line (table end)."""
    global _estimate_current, _estimate_timer_id
    if _estimate_current is None:
        return  # not in estimate mode, ignore spurious backtick
    _estimate_timer_id += 1  # invalidate watchdog timer
    if _estimate_data:
        _estimate_current.cost_schedule = dict(_estimate_data)
        # Update cost from schedule for next training step
        next_pct = _estimate_current.current_pct + 5
        if next_pct in _estimate_current.cost_schedule:
            exp_cost, money_cost = _estimate_current.cost_schedule[next_pct]
            _estimate_current.cost = exp_cost
            _estimate_current.money_cost = money_cost
    else:
        _echo(f'>>> No estimate data for {_estimate_current.name}, skipping', 'yellow')
    _estimate_current = None
    _next_estimate()


# --- Training success/failure detection ---

def train_success(exp_cost, money_cost):
    """Called by TF trigger on 'That cost you N exp and N gold.'"""
    global _training_entry, _current_exp
    entry = _training_entry
    if not entry:
        return
    _training_entry = None
    exp_cost = int(exp_cost)
    money_cost = int(money_cost)
    entry.current_pct += 5
    entry.reps = max(0, entry.reps - 1)
    # Update cost from schedule for the next step
    next_pct = entry.current_pct + 5
    if next_pct in entry.cost_schedule:
        entry.cost, entry.money_cost = entry.cost_schedule[next_pct]
    # Sync current_pct to copies in other guilds
    _sync_entry(entry)
    if _current_exp > 0:
        _current_exp = max(0, _current_exp - exp_cost)
    verb = 'study' if entry.type == 'spell' else 'train'
    next_info = f', next: {_format_exp(entry.cost)}' if entry.reps > 0 else ''
    exp_info = f', exp: {_format_exp(_current_exp)}' if _current_exp > 0 else ''
    _echo(f'>>> {verb} {entry.name}: now {entry.current_pct}% '
          f'({entry.reps} reps left{next_info}{exp_info})', 'green')
    _autosave()


def train_failure():
    """Called by TF trigger on training failure messages."""
    global _training_entry
    entry = _training_entry
    if not entry:
        return
    _training_entry = None
    _echo(f'>>> Training failed for {entry.name}', 'yellow')


# --- Training execution ---

def train_all(include_zero=False):
    """Send train/study commands for captured entries with reps > 0."""
    count = 0
    for entry in _unique_entries():
        if entry.excluded or entry.reps <= 0:
            continue
        if not include_zero and entry.current_pct == 0:
            continue
        verb = 'study' if entry.type == 'spell' else 'train'
        tf.eval(f'/repeat -S {entry.reps} 1 {verb} {entry.name}')
        count += entry.reps
    if count > 0:
        _echo(f'>>> Training {count} total repetitions', 'green')
    else:
        _echo('>>> Nothing to train', 'yellow')


def train_next(name=None):
    """Train/study 5% of a specific or cheapest non-0% entry."""
    global _training_entry
    if name:
        entry = _find_entry(name)
        if not entry:
            _echo(f'>>> Unknown skill/spell: {name}', 'red')
            return
        if entry.reps <= 0:
            _echo(f'>>> {name}: already at target', 'yellow')
            return
    else:
        # Find cheapest non-excluded, non-0%, with reps remaining
        candidates = []
        for e in _unique_entries():
            if e.excluded or e.reps <= 0 or e.current_pct == 0:
                continue
            candidates.append(e)
        if not candidates:
            _echo('>>> Nothing to train', 'yellow')
            return
        candidates.sort(key=_sort_key)
        entry = candidates[0]

    verb = 'study' if entry.type == 'spell' else 'train'
    _echo(f'>>> {verb} {entry.name} ({_format_exp(entry.cost)} exp)', 'green')
    _training_entry = entry
    _send(f'{verb} {entry.name}')


# --- Plan ---

def _compute_plan(include_zero=False):
    """Compute optimal exp allocation across all guilds, cheapest steps first.

    Returns (guild_plans, ordered_gids, total_exp, total_money, remaining)
    where guild_plans is {guild_id: [(entry, [(pct, exp_cost, money_cost), ...])]},
    or None if nothing to plan or no exp set.
    """
    if _current_exp <= 0:
        _echo('>>> No exp set. Use: /training check <exp> or type exp in game', 'yellow')
        return None

    # Build list of all individual 5% steps: (cost, money, entry, pct)
    steps = []
    for entry in _unique_entries():
        if entry.excluded or entry.reps <= 0:
            continue
        if not include_zero and entry.current_pct == 0:
            continue
        if entry.cost_schedule:
            pct = entry.current_pct + 5
            while pct <= entry.target_pct and pct in entry.cost_schedule:
                exp_cost, money_cost = entry.cost_schedule[pct]
                steps.append((exp_cost, money_cost, entry, pct))
                pct += 5
        elif entry.cost > 0:
            # No schedule — skip, costs would be inaccurate
            _echo(f'>>> WARNING: {entry.name} has no cost schedule, '
                  f'skipping (run full capture to fix)', 'yellow')

    if not steps:
        _echo('>>> Nothing to plan', 'yellow')
        return None

    # Sort by priority (desc), cost (asc), soft order, name
    steps.sort(key=_step_sort_key)

    # Allocate from budget
    remaining = _current_exp
    # planned[entry] = [(pct, exp_cost, money_cost), ...]
    planned = {}
    total_exp = 0
    total_money = 0
    for exp_cost, money_cost, entry, pct in steps:
        if exp_cost > remaining:
            continue
        remaining -= exp_cost
        total_exp += exp_cost
        total_money += money_cost
        planned.setdefault(id(entry), (entry, []))[1].append((pct, exp_cost, money_cost))

    if not planned:
        _echo(f'>>> Cannot afford any training with {_format_exp(_current_exp)} exp', 'yellow')
        return None

    # Group by guild
    guild_plans = {}  # guild_id -> [(entry, steps)]
    for entry, entry_steps in planned.values():
        gid = entry.guild or 'unknown'
        guild_plans.setdefault(gid, []).append((entry, entry_steps))

    # Order guilds: reinc order first, then any extra
    ordered_gids = []
    for gc in _reinc.values():
        if gc.id in guild_plans:
            ordered_gids.append(gc.id)
    for gid in sorted(guild_plans.keys()):
        if gid not in ordered_gids:
            ordered_gids.append(gid)

    return guild_plans, ordered_gids, total_exp, total_money, remaining


def plan(include_zero=False):
    """Display optimal exp allocation across all guilds."""
    result = _compute_plan(include_zero)
    if result is None:
        return
    guild_plans, ordered_gids, total_exp, total_money, remaining = result

    total_reps = sum(len(s) for _, s in
                     [item for entries in guild_plans.values() for item in entries])
    _echo(f'>>> Training plan with {_format_exp(_current_exp)} exp '
          f'({total_reps} steps, {_format_exp(total_exp)} exp, '
          f'{_format_exp(remaining)} remaining):', 'green')

    for gid in ordered_gids:
        gc = _reinc.get(gid)
        if gc:
            _echo(f'>>> === {gc.id} ({gc.name}) ===', 'white')
        else:
            _echo(f'>>> === {gid} ===', 'white')

        entries = guild_plans[gid]
        entries.sort(key=lambda t: _sort_key(t[0]))  # sort by priority, cost, soft order
        guild_exp = 0
        for entry, entry_steps in entries:
            verb = 'study' if entry.type == 'spell' else 'train'
            reps = len(entry_steps)
            last_pct = entry_steps[-1][0]
            step_exp = sum(s[1] for s in entry_steps)
            guild_exp += step_exp
            _echo(f'>>>   {verb} {entry.name}: '
                  f'{entry.current_pct}% -> {last_pct}% '
                  f'({reps}x, {_format_exp(step_exp)} exp)')
        _echo(f'>>>   subtotal: {_format_exp(guild_exp)} exp', 'cyan')


_plan_go_info = None  # stores summary for delayed callback


def _estimate_gold(total_exp):
    """Estimate gold needed for training.

    Rate: 400 gold per 1M exp.  Round total_exp up to the nearest 1M,
    but if less than 100k short of that boundary, round to the *next* 1M.
    E.g. 11.94M -> ceil 12M -> gap 60k < 100k -> 13M -> 5200 gold.
    """
    rounded = math.ceil(total_exp / 1_000_000) * 1_000_000
    if (rounded - total_exp) < 100_000:
        rounded += 1_000_000
    return (rounded // 1_000_000) * 400


def plan_go(include_zero=False):
    """Execute the training plan — withdraw gold, navigate to each guild, train, return."""
    global _plan_go_info, _current_exp
    result = _compute_plan(include_zero)
    if result is None:
        return
    guild_plans, ordered_gids, total_exp, total_money, remaining = result

    # Verify all guilds have reinc config (needed for navigation macros)
    for gid in ordered_gids:
        if gid not in _reinc:
            _echo(f'>>> Guild "{gid}" has no reinc config — cannot navigate. '
                  f'Use: /training reinc set {gid} <levels>', 'red')
            return

    # Withdraw gold from bank (from CS: 2 n, w, withdraw, e, 2 s)
    gold = _estimate_gold(total_exp)
    tf.eval('!2 n')
    tf.eval('!w')
    tf.eval(f'!withdraw {gold}')
    tf.eval('!e')
    tf.eval('!2 s')

    total_reps = 0
    trained_items = []  # (name, start_pct, end_pct)
    for gid in ordered_gids:
        gc = _reinc[gid]
        entries = guild_plans[gid]
        entries.sort(key=lambda t: _sort_key(t[0]))

        # Navigate to trainer
        tf.eval(f'/{gc.to_macro}')

        # Train each entry and update in-memory state
        for entry, entry_steps in entries:
            verb = 'study' if entry.type == 'spell' else 'train'
            start_pct = entry.current_pct
            for pct, exp_cost, money_cost in entry_steps:
                tf.eval(f'!{verb} {entry.name}')
                total_reps += 1
            # Optimistically update entry to post-training state
            last_pct = entry_steps[-1][0]
            trained_items.append((entry.name, start_pct, last_pct))
            entry.current_pct = last_pct
            entry.reps = max(0, (entry.target_pct - last_pct) // 5)
            # Update cost to next step from schedule
            next_pct = last_pct + 5
            if next_pct in entry.cost_schedule:
                entry.cost, entry.money_cost = entry.cost_schedule[next_pct]
            _sync_entry(entry)

        # Return to CS
        tf.eval(f'/{gc.from_macro}')

    # Update exp and save
    _current_exp = remaining
    _autosave()

    _plan_go_info = {
        'guilds': len(ordered_gids),
        'reps': total_reps,
        'exp': total_exp,
        'gold': gold,
        'remaining': remaining,
        'trained': trained_items,
    }
    tf.eval('/repeat -1 2 /python _trainer._plan_go_summary()')


def _plan_go_summary():
    """Delayed callback — print what was queued, then clear."""
    global _plan_go_info
    if _plan_go_info is None:
        return
    info = _plan_go_info
    _plan_go_info = None
    _echo(f'>>> Plan queued: {info["guilds"]} guild(s), {info["reps"]} steps, '
          f'{_format_exp(info["exp"])} exp, {info["gold"]} gold withdrawn, '
          f'{_format_exp(info["remaining"])} exp remaining',
          'green')
    for name, start_pct, end_pct in info['trained']:
        _echo(f'>>>   {name} {start_pct}% -> {end_pct}%', 'cyan')


# --- Status display ---

def status(include_zero=False):
    """Show captured skills/spells with reps, costs, and trainability."""
    if not any(_spells.values()) and not any(_skills.values()):
        _echo('>>> No captured data. Use: /training guild <id> then /training capture', 'yellow')
        return

    trainable_count = 0
    total_zero_skills = 0
    total_zero_spells = 0

    # Determine guild display order: reinc order first, then any extra
    ordered_gids = []
    for gc in _reinc.values():
        if gc.id in _guild_ids():
            ordered_gids.append(gc.id)
    for gid in sorted(_guild_ids()):
        if gid not in ordered_gids:
            ordered_gids.append(gid)

    for gid in ordered_gids:
        gc = _reinc.get(gid)
        if gc:
            _echo(f'>>> === {gc.id} ({gc.name}, {gc.levels} levels) ===', 'white')
        else:
            _echo(f'>>> === {gid} ===', 'white')

        guild_spells = _spells.get(gid, {})
        guild_skills = _skills.get(gid, {})

        for label, entries, verb in [('Spells', guild_spells, 'study'),
                                     ('Skills', guild_skills, 'train')]:
            if not entries:
                continue
            shown = []
            for e in entries.values():
                if e.reps <= 0:
                    continue
                if not include_zero and e.current_pct == 0:
                    continue
                shown.append(e)

            if not shown:
                continue

            _echo(f'>>> --- {label} ({len(shown)} to {verb}) ---', 'white')
            for e in shown:
                flags = []
                if e.priority > 0:
                    flags.append(f'PRIO {e.priority}')
                if e.excluded:
                    flags.append('EXCLUDED')
                if _current_exp > 0 and e.cost > 0 and _current_exp >= e.cost:
                    flags.append('TRAINABLE')
                    trainable_count += 1
                if e.target_pct != e.max_pct:
                    target_info = f' target {e.target_pct}%'
                else:
                    target_info = ''
                flag_str = f' [{", ".join(flags)}]' if flags else ''
                total = _total_remaining_cost(e)
                if total is not None:
                    cost_info = f'{_format_exp(total)} total, {_format_exp(e.cost)} next'
                else:
                    cost_info = f'{_format_exp(e.cost)} next'
                _echo(f'>>>   {e.name}: {e.current_pct}/{e.max_pct}%{target_info} '
                      f'({e.reps} reps, {cost_info}){flag_str}')

        # Count hidden 0% entries for this guild
        zero_skills = sum(1 for e in guild_skills.values()
                         if e.reps > 0 and e.current_pct == 0)
        zero_spells = sum(1 for e in guild_spells.values()
                         if e.reps > 0 and e.current_pct == 0)
        total_zero_skills += zero_skills
        total_zero_spells += zero_spells

    if not include_zero and (total_zero_skills or total_zero_spells):
        _echo(f'>>> ({total_zero_skills} skills, {total_zero_spells} spells at 0% hidden'
              f' — use "status all" to show)', 'cyan')

    if _current_exp > 0:
        _echo(f'>>> Exp: {_format_exp(_current_exp)}, {trainable_count} trainable now', 'green')


# --- Trainability tracker ---

def set_exp(amount):
    """Set current exp explicitly — runs full check."""
    global _current_exp
    _current_exp = int(amount)
    check_trainable()


def update_exp(amount):
    """Update exp silently (from exptrack callback). Only echoes newly trainable."""
    global _current_exp
    old_exp = _current_exp
    _current_exp = int(amount)
    if old_exp == 0 and _current_exp > 0:
        _echo(f'>>> Exp tracked: {_format_exp(_current_exp)}', 'green')
    if old_exp == _current_exp:
        return
    # Find entries with newly affordable reps (dedupe by name — cheapest guild wins)
    alerts_by_name = {}  # name -> (entry, old_reps, new_reps)
    for entry in _all_entries():
        if entry.excluded or entry.reps <= 0 or entry.current_pct == 0:
            continue
        if entry.cost <= 0:
            continue
        old_reps = _affordable_reps(entry, old_exp)
        new_reps = _affordable_reps(entry, _current_exp)
        if new_reps > old_reps:
            prev = alerts_by_name.get(entry.name)
            if prev is None or entry.cost < prev[0].cost:
                alerts_by_name[entry.name] = (entry, old_reps, new_reps)
    alerts = list(alerts_by_name.values())
    if alerts:
        alerts.sort(key=lambda t: _sort_key(t[0]))
        for e, old_reps, new_reps in alerts:
            verb = 'study' if e.type == 'spell' else 'train'
            if old_reps == 0:
                _echo(f'>>> NOW TRAINABLE: {verb} {e.name} — '
                      f'{_format_exp(e.cost)} exp ({e.current_pct}/{e.max_pct}%) '
                      f'[{new_reps}x]', 'green')
            else:
                _echo(f'>>> NOW TRAINABLE: {verb} {e.name} — '
                      f'{old_reps}x → {new_reps}x ({e.current_pct}/{e.max_pct}%)',
                      'green')


def _affordable_reps(entry, exp_budget):
    """Count how many 5% steps can be trained with exp_budget using cost_schedule."""
    if not entry.cost_schedule:
        # No schedule — only count 1 rep (next step) to avoid overestimating
        return 1 if entry.cost > 0 and exp_budget >= entry.cost else 0
    reps = 0
    remaining = exp_budget
    pct = entry.current_pct + 5
    while pct <= entry.target_pct and pct in entry.cost_schedule:
        cost, _ = entry.cost_schedule[pct]
        if cost > remaining:
            break
        remaining -= cost
        reps += 1
        pct += 5
    return min(reps, entry.reps)


def check_trainable(include_zero=False, filter_exp=True):
    """Show trainable items. If filter_exp and exp is known, only show affordable."""
    has_exp = _current_exp > 0
    use_exp_filter = filter_exp and has_exp

    trainable = []
    for entry in _unique_entries():
        if entry.excluded or entry.reps <= 0:
            continue
        if not include_zero and entry.current_pct == 0:
            continue
        if use_exp_filter and _current_exp < entry.cost:
            continue
        trainable.append(entry)

    if trainable:
        trainable.sort(key=_sort_key)
        if use_exp_filter:
            _echo(f'>>> {len(trainable)} trainable with {_format_exp(_current_exp)} exp:', 'green')
        else:
            _echo(f'>>> {len(trainable)} with training remaining:', 'green')
        for e in trainable:
            verb = 'study' if e.type == 'spell' else 'train'
            target_info = f' target {e.target_pct}%' if e.target_pct != e.max_pct else ''
            guild_info = f' [{e.guild}]' if e.guild else ''
            if has_exp:
                reps = _affordable_reps(e, _current_exp)
                reps_info = f' [{reps}x]' if reps > 0 else ''
            else:
                reps_info = ''
            prio_info = f' [PRIO {e.priority}]' if e.priority > 0 else ''
            _echo(f'>>>   {verb} {e.name} — {_format_exp(e.cost)} exp '
                  f'({e.current_pct}/{e.max_pct}%{target_info}){reps_info}{guild_info}{prio_info}')
    else:
        if use_exp_filter:
            _echo(f'>>> Nothing trainable with {_format_exp(_current_exp)} exp', 'yellow')
        else:
            _echo('>>> Nothing to train', 'yellow')


# --- Exclude / include ---

def exclude(name):
    _excludes.add(name)
    # Mark in all guilds
    for guild_data in list(_spells.values()) + list(_skills.values()):
        if name in guild_data:
            guild_data[name].excluded = True
    _echo(f'>>> Excluded: {name}', 'green')
    _autosave()


def include(name):
    _excludes.discard(name)
    # Unmark in all guilds
    for guild_data in list(_spells.values()) + list(_skills.values()):
        if name in guild_data:
            guild_data[name].excluded = False
    _echo(f'>>> Included: {name}', 'green')
    _autosave()


# --- Priority ---

def set_priority(name, value=1):
    """Set hard priority for a skill/spell name."""
    value = int(value)
    _priorities[name] = value
    # Apply to all copies across guilds
    for copy in _find_all_copies(name):
        copy.priority = value
    _echo(f'>>> Priority: {name} = {value}', 'green')
    _autosave()


def clear_priority(name=None):
    """Clear priority for one or all entries."""
    global _priorities
    if name:
        if name in _priorities:
            del _priorities[name]
            for copy in _find_all_copies(name):
                copy.priority = 0
            _echo(f'>>> Priority cleared: {name}', 'green')
        else:
            _echo(f'>>> {name} has no priority set', 'yellow')
    else:
        _priorities = {}
        for entry in _all_entries():
            entry.priority = 0
        _echo('>>> All priorities cleared', 'green')
    _autosave()


def show_priorities():
    """List all entries with hard priority set."""
    if not _priorities:
        _echo('>>> No priorities set. Use: /training priority <name> [value]', 'yellow')
        return
    _echo(f'>>> {len(_priorities)} prioritized entries:', 'green')
    for name, value in sorted(_priorities.items(), key=lambda t: (-t[1], t[0])):
        entry = _find_entry(name)
        if entry:
            _echo(f'>>>   [{value}] {name}: {entry.current_pct}/{entry.max_pct}% '
                  f'({_format_exp(entry.cost)} exp) [{entry.guild}]')
        else:
            _echo(f'>>>   [{value}] {name} (not captured)')


# --- Target ---

def set_target(name, pct=None):
    entry = _find_entry(name)
    if not entry:
        _echo(f'>>> Unknown skill/spell: {name}', 'red')
        return
    if pct is None:
        entry.target_pct = entry.max_pct
        _echo(f'>>> {name}: target reset to max ({entry.max_pct}%)', 'green')
    else:
        pct = int(pct)
        if pct > entry.max_pct:
            pct = entry.max_pct
        entry.target_pct = pct
        _echo(f'>>> {name}: target set to {pct}%', 'green')
    entry.reps = max(0, (entry.target_pct - entry.current_pct) // 5)


def set_target_all(pct):
    """Set target % for all captured entries (capped at each entry's max)."""
    pct = int(pct)
    count = 0
    for entry in _all_entries():
        t = min(pct, entry.max_pct)
        entry.target_pct = t
        entry.reps = max(0, (t - entry.current_pct) // 5)
        count += 1
    _echo(f'>>> Global target set to {pct}% ({count} entries)', 'green')


def clear():
    """Clear all training data (preserves reinc config)."""
    global _spells, _skills, _excludes, _priorities, _current_exp, _active_guild
    _spells = {}
    _skills = {}
    _excludes = set()
    _priorities = {}
    _current_exp = 0
    _active_guild = None
    _echo('>>> Cleared all training data', 'green')


# --- Dispatch ---

def _dispatch_reinc(rest):
    """Handle /training reinc subcommands."""
    parts = rest.strip().split(None, 1)
    sub = parts[0] if parts else ''
    sub_rest = parts[1].strip() if len(parts) > 1 else ''

    if sub == 'set':
        # /training reinc set <id> <levels> [name...]
        set_parts = sub_rest.split(None, 1)
        if len(set_parts) < 1:
            _echo('>>> Usage: /training reinc set <id> <levels> [name]', 'yellow')
            return
        guild_id = set_parts[0]
        remaining = set_parts[1] if len(set_parts) > 1 else ''
        # remaining is "<levels> [name...]" or just "<levels>"
        rem_parts = remaining.split(None, 1)
        if not rem_parts or not rem_parts[0].isdigit():
            _echo('>>> Usage: /training reinc set <id> <levels> [name]', 'yellow')
            return
        levels = rem_parts[0]
        name = rem_parts[1] if len(rem_parts) > 1 else None
        reinc_set(guild_id, levels, name)
    elif sub == 'show' or sub == '':
        reinc_show()
    elif sub == 'clear':
        reinc_clear()
    elif sub == 'init_start':
        init_start()
    elif sub == 'init_next':
        init_next()
    else:
        _echo('>>> Usage: /training reinc set|show|clear|init_start|init_next', 'yellow')


def _dispatch_priority(rest):
    """Handle /training priority subcommands."""
    parts = rest.strip().split(None, 1)
    sub = parts[0] if parts else ''
    sub_rest = parts[1].strip() if len(parts) > 1 else ''

    if sub == 'show' or sub == '':
        show_priorities()
    elif sub == 'clear':
        clear_priority(sub_rest if sub_rest else None)
    elif sub == 'dump':
        dump_order()
    else:
        # /training priority <name> [value]
        # sub is the name, sub_rest may be the value
        name_parts = rest.strip().rsplit(None, 1)
        if len(name_parts) == 2 and name_parts[1].isdigit():
            set_priority(name_parts[0], int(name_parts[1]))
        else:
            set_priority(rest.strip())


def _help_quick():
    """Show quick help with most-used commands."""
    _echo('>>> Training — common commands:')
    _echo('>>>   plan [all]         — plan optimal training with exp budget')
    _echo('>>>   plan go [all]      — execute plan (walk + train + walk back)')
    _echo('>>>   check [all|<exp>]  — show trainable / set exp')
    _echo('>>>   train [next|name]  — train 5% of cheapest or named')
    _echo('>>>   visit <id>         — navigate to guild trainer')
    _echo('>>>   status [all]       — show captured data')
    _echo('>>>   capture [full]     — capture skills/spells from MUD')
    _echo('>>> Type /training help for all commands, /training help <topic> for details.')
    _echo('>>> Topics: reinc, capture, training, config, system')


def _help_full():
    """Show full help with all commands grouped by topic."""
    _echo('>>> --- Common ---')
    _echo('>>>   plan [all]           — plan optimal training with exp budget')
    _echo('>>>   plan go [all]        — execute plan (walk + train + walk back)')
    _echo('>>>   check [all|<exp>]    — show trainable / set exp')
    _echo('>>>   train [next|name]    — train 5% of cheapest or named')
    _echo('>>>   visit <id>           — navigate to guild trainer')
    _echo('>>>   status [all]         — show captured data')
    _echo('>>> --- Reinc Setup (help reinc) ---')
    _echo('>>>   reinc set <id> <levels> [name] — add/update guild config')
    _echo('>>>   reinc show|clear     — show or clear reinc config')
    _echo('>>>   reinc init_start     — clear data and begin guild init sequence')
    _echo('>>>   reinc init_next      — travel to next guild, capture, travel back')
    _echo('>>>   guild [id]           — show or set active guild')
    _echo('>>>   visit_cs [id]        — return from guild trainer')
    _echo('>>> --- Capture (help capture) ---')
    _echo('>>>   capture [full] [spells|skills] — capture from MUD (full re-fetches estimates)')
    _echo('>>>   capture allguilds    — quick recapture all guilds (preserves cost schedules)')
    _echo('>>>   capture allguilds estimate — recapture all guilds with estimates')
    _echo('>>> --- Training (help training) ---')
    _echo('>>>   go [all]             — train all to target (default: started only)')
    _echo('>>>   train [next|name]    — train 5% of cheapest or named')
    _echo('>>> --- Config (help config) ---')
    _echo('>>>   exclude <name>       — exclude from training')
    _echo('>>>   include <name>       — remove exclusion')
    _echo('>>>   priority <name> [val] — set hard priority (default: 1)')
    _echo('>>>   priority show|clear|dump — manage priorities')
    _echo('>>>   target <pct>         — set global target % for all entries')
    _echo('>>>   target <name> [pct]  — set per-skill target (omit pct to reset)')
    _echo('>>> --- System (help system) ---')
    _echo('>>>   save / load / clear / reload')


def _help_topic(topic):
    """Show detailed help for a specific topic."""
    if topic == 'reinc':
        _echo('>>> --- Reinc Setup ---')
        _echo('>>>   reinc set <id> <levels> [name] — add/update guild config')
        _echo('>>>   reinc show           — show reinc config')
        _echo('>>>   reinc clear          — clear reinc config')
        _echo('>>>   reinc init_start     — clear data and begin guild init sequence')
        _echo('>>>   reinc init_next      — travel to next guild, capture, travel back')
        _echo('>>>   guild [id]           — show or set active guild for captures')
        _echo('>>>   visit <id>           — navigate to guild trainer (sets active guild)')
        _echo('>>>   visit_cs [id]        — return from guild trainer')
    elif topic == 'capture':
        _echo('>>> --- Capture ---')
        _echo('>>>   capture              — capture spells and skills from MUD')
        _echo('>>>   capture spells       — capture spells only')
        _echo('>>>   capture skills       — capture skills only')
        _echo('>>>   capture full         — capture and re-fetch all estimates')
        _echo('>>>   capture full spells  — capture spells with full estimates')
        _echo('>>>   capture full skills  — capture skills with full estimates')
        _echo('>>>   capture allguilds    — quick recapture all guilds (preserves cost schedules)')
        _echo('>>>   capture allguilds estimate — recapture all guilds with estimates')
    elif topic == 'training':
        _echo('>>> --- Training ---')
        _echo('>>>   plan [all]           — plan optimal training with current exp budget')
        _echo('>>>   plan go [all]        — execute plan (walk + train + walk back per guild)')
        _echo('>>>   train next           — train 5% of cheapest trainable')
        _echo('>>>   train <name>         — train 5% of named skill/spell')
        _echo('>>>   go [all]             — train all to target (default: started only)')
        _echo('>>>   check                — show affordable (if exp known) or all trainable')
        _echo('>>>   check all            — show all trainable regardless of cost')
        _echo('>>>   check <exp>          — set exp and show affordable')
        _echo('>>>   status [all]         — show captured data (default: hides 0%)')
    elif topic == 'config':
        _echo('>>> --- Config ---')
        _echo('>>>   exclude <name>       — exclude skill/spell from training')
        _echo('>>>   include <name>       — remove exclusion')
        _echo('>>>   priority <name> [value] — set hard priority (default: 1, higher trains first)')
        _echo('>>>   priority show        — list all prioritized entries')
        _echo('>>>   priority clear [name] — clear one or all priorities')
        _echo('>>>   priority dump        — write order.txt for soft priority editing')
        _echo('>>>   target <pct>         — set global target % for all entries')
        _echo('>>>   target <name> [pct]  — set per-skill target (omit pct to reset to max)')
    elif topic == 'system':
        _echo('>>> --- System ---')
        _echo('>>>   save                 — save state to disk')
        _echo('>>>   load                 — load state from disk')
        _echo('>>>   clear                — clear all training data')
        _echo('>>>   reload               — reload modules')
    else:
        _echo(f'>>> Unknown topic: {topic}', 'red')
        _echo('>>> Topics: reinc, capture, training, config, system')


def dispatch(args_str):
    """Route /training subcommands."""
    parts = args_str.strip().split(None, 1)
    cmd = parts[0] if parts else ''
    rest = parts[1].strip() if len(parts) > 1 else ''

    # Handle help early (before 'all' flag parsing)
    if cmd == 'help':
        if rest:
            _help_topic(rest)
        else:
            _help_full()
        return
    elif cmd == '':
        _help_quick()
        return

    # Handle capture early (before 'all' flag parsing which conflicts with 'allguilds')
    if cmd == 'capture':
        if rest == 'allguilds':
            capture_all_guilds(estimate=False)
            return
        if rest == 'allguilds estimate':
            capture_all_guilds(estimate=True)
            return
        # Parse 'full' flag
        full = False
        capture_rest = rest
        if capture_rest == 'full' or capture_rest.startswith('full '):
            full = True
            capture_rest = capture_rest[4:].strip()
        if capture_rest == 'spells':
            capture_spells(full=full)
        elif capture_rest == 'skills':
            capture_skills(full=full)
        else:
            capture_all(full=full)
        return

    # Parse 'all' flag from rest
    include_all = False
    if rest == 'all' or rest.startswith('all '):
        include_all = True
        rest = rest[3:].strip()
    elif cmd == 'go':
        train_all(include_zero=include_all)
    elif cmd == 'status':
        status(include_zero=include_all)
    elif cmd == 'clear':
        clear()
    elif cmd == 'check':
        if rest:
            set_exp(_parse_exp(rest))
        elif include_all:
            check_trainable(include_zero=True, filter_exp=False)
        else:
            check_trainable()
    elif cmd == 'plan':
        if rest == 'go' or rest.startswith('go '):
            plan_go(include_zero=include_all or rest.endswith(' all'))
        else:
            plan(include_zero=include_all)
    elif cmd == 'train':
        if rest and rest != 'next':
            train_next(rest)
        else:
            train_next()
    elif cmd == 'exclude':
        if rest:
            exclude(rest)
        else:
            _echo('>>> Usage: /training exclude <name>', 'yellow')
    elif cmd == 'include':
        if rest:
            include(rest)
        else:
            _echo('>>> Usage: /training include <name>', 'yellow')
    elif cmd == 'priority':
        _dispatch_priority(rest)
    elif cmd == 'target':
        target_parts = rest.rsplit(None, 1)
        if not rest:
            _echo('>>> Usage: /training target [name] <pct>', 'yellow')
        elif rest.isdigit():
            # Just a number: global target
            set_target_all(rest)
        elif len(target_parts) == 2 and target_parts[1].isdigit():
            set_target(target_parts[0], target_parts[1])
        else:
            set_target(rest)
    elif cmd == 'guild':
        if rest:
            set_active_guild(rest)
        else:
            show_active_guild()
    elif cmd == 'reinc':
        _dispatch_reinc(rest)
    elif cmd == 'visit':
        if rest:
            visit(rest)
        else:
            if _reinc:
                ids = ', '.join(f'{gc.id} ({gc.name})' for gc in _reinc.values())
                _echo(f'>>> Usage: /training visit <id>  — available: {ids}', 'yellow')
            else:
                _echo('>>> Usage: /training visit <id>  — no guilds configured (see /training help reinc)', 'yellow')
    elif cmd == 'visit_cs':
        visit_cs(rest if rest else None)
    elif cmd == 'save':
        save()
    elif cmd == 'load':
        load()
    else:
        _echo(f'>>> Unknown command: {cmd}. Try /training help', 'red')
