from __future__ import annotations

import argparse
import csv
import json
from dataclasses import dataclass, replace
from datetime import date, timedelta
from pathlib import Path
from typing import Iterable

MASTERY_ORDER = {'U': -1, 'M0': 0, 'M1': 1, 'M2': 2, 'M3': 3, 'M4': 4}
CORE_WEIGHT = {'S': 30, 'R': 30, 'A': 20, 'B': 12, 'C': 6}
MASTERY_WEIGHT = {'M0': 30, 'M1': 24, 'M2': 14, 'M3': 6, 'M4': 0}
BASE_INTERVAL = {'M0': 1, 'M1': 2, 'M2': 5, 'M3': 14, 'M4': 45}
CORE_INTERVAL_FACTOR = {'S': 0.70, 'R': 0.70, 'A': 1.0, 'B': 1.25, 'C': 1.75}
COST_MINUTES = {'U': 5, 'M0': 4, 'M1': 4, 'M2': 3, 'M3': 2, 'M4': 2}


@dataclass(frozen=True)
class CardMeta:
    card_id: int
    legacy_v10_id: int
    title: str
    difficulty: str
    core: str
    target: str
    prereqs: tuple[int, ...]
    followups: tuple[int, ...]
    rollback: tuple[int, ...]
    kind: str


@dataclass
class CardState:
    card_id: int
    mastery: str = 'U'
    last_review: date | None = None
    due_date: date | None = None
    lapses: int = 0
    streak: int = 0
    last_result: int | None = None
    note: str = ''


@dataclass(frozen=True)
class PlanItem:
    card_id: int
    title: str
    kind: str
    mastery: str
    score: float
    minutes: int


def load_metadata(path: Path) -> dict[int, CardMeta]:
    data = json.loads(path.read_text(encoding='utf-8'))
    cards: dict[int, CardMeta] = {}
    for row in data['cards']:
        meta = CardMeta(
            card_id=int(row['card_id']),
            legacy_v10_id=int(row['legacy_v10_id']),
            title=row['title'],
            difficulty=row['difficulty'],
            core=row['priority'],
            target=row['target_mastery'],
            prereqs=tuple(int(x) for x in row.get('prerequisites', [])),
            followups=tuple(int(x) for x in row.get('followups', [])),
            rollback=tuple(int(x) for x in row.get('rollback', [])),
            kind=row.get('kind', 'knowledge'),
        )
        if meta.card_id in cards:
            raise ValueError(f'重复 card_id: {meta.card_id}')
        cards[meta.card_id] = meta
    expected = int(data.get('knowledge_cards', 0)) + int(data.get('graphs', 0))
    if len(cards) != expected:
        raise ValueError(f'元数据计数不一致：声明 {expected}，实际 {len(cards)}')
    valid = set(cards)
    for meta in cards.values():
        for ref in (*meta.prereqs, *meta.followups, *meta.rollback):
            if ref not in valid:
                raise ValueError(f'卡 {meta.card_id} 引用了不存在的卡 {ref}')
    return cards


def _parse_date(value: str) -> date | None:
    value = (value or '').strip()
    return date.fromisoformat(value) if value else None


def load_states(path: Path, cards: dict[int, CardMeta]) -> dict[int, CardState]:
    states = {cid: CardState(card_id=cid) for cid in cards}
    if not path.exists():
        return states
    with path.open('r', encoding='utf-8', newline='') as f:
        for row in csv.DictReader(f):
            cid = int(row['card_id'])
            if cid not in states:
                continue
            mastery = (row.get('mastery') or 'U').strip()
            if mastery not in MASTERY_ORDER:
                raise ValueError(f'卡 {cid} mastery 非法: {mastery}')
            states[cid] = CardState(
                card_id=cid,
                mastery=mastery,
                last_review=_parse_date(row.get('last_review', '')),
                due_date=_parse_date(row.get('due_date', '')),
                lapses=int(row.get('lapses') or 0),
                streak=int(row.get('streak') or 0),
                last_result=int(row['last_result']) if row.get('last_result') not in (None, '') else None,
                note=row.get('note', ''),
            )
    return states


def save_states(path: Path, states: dict[int, CardState]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open('w', encoding='utf-8', newline='') as f:
        writer = csv.DictWriter(
            f,
            fieldnames=['card_id', 'mastery', 'last_review', 'due_date', 'lapses', 'streak', 'last_result', 'note'],
        )
        writer.writeheader()
        for cid in sorted(states):
            st = states[cid]
            writer.writerow({
                'card_id': cid,
                'mastery': st.mastery,
                'last_review': st.last_review.isoformat() if st.last_review else '',
                'due_date': st.due_date.isoformat() if st.due_date else '',
                'lapses': st.lapses,
                'streak': st.streak,
                'last_result': '' if st.last_result is None else st.last_result,
                'note': st.note,
            })


def interval_days(mastery: str, core: str, streak: int = 0) -> int:
    if mastery == 'U':
        return 0
    base = BASE_INTERVAL[mastery] * CORE_INTERVAL_FACTOR[core]
    streak_factor = min(1.30, 1.0 + max(0, streak - 1) * 0.05)
    return max(1, round(base * streak_factor))


def apply_result(state: CardState, result: int, core: str, today: date | None = None) -> CardState:
    """记录一次与当前掌握层级匹配的挑战结果。

    result: 0=错误；1=部分正确/明显犹豫；2=通过当前层级挑战。
    一次最多升一级；失败最多降一级。
    """
    if result not in (0, 1, 2):
        raise ValueError('result 必须是 0、1 或 2')
    today = today or date.today()
    current = state.mastery

    if result == 0:
        mastery = 'M0' if current == 'U' else f"M{max(0, MASTERY_ORDER[current] - 1)}"
        return replace(
            state,
            mastery=mastery,
            last_review=today,
            due_date=today + timedelta(days=1),
            lapses=state.lapses + 1,
            streak=0,
            last_result=0,
        )

    if result == 1:
        mastery = 'M0' if current == 'U' else current
        days = max(1, interval_days(mastery, core, state.streak) // 2)
        return replace(
            state,
            mastery=mastery,
            last_review=today,
            due_date=today + timedelta(days=days),
            streak=0,
            last_result=1,
        )

    mastery = 'M1' if current == 'U' else f"M{min(4, MASTERY_ORDER[current] + 1)}"
    streak = state.streak + 1
    return replace(
        state,
        mastery=mastery,
        last_review=today,
        due_date=today + timedelta(days=interval_days(mastery, core, streak)),
        streak=streak,
        last_result=2,
    )


def _outdegree(cards: dict[int, CardMeta]) -> dict[int, int]:
    result = {cid: 0 for cid in cards}
    for meta in cards.values():
        for p in meta.prereqs:
            result[p] += 1
    return result


def review_score(meta: CardMeta, state: CardState, today: date, outdegree: int) -> float:
    overdue = 0 if not state.due_date else max(0, (today - state.due_date).days)
    return (
        CORE_WEIGHT[meta.core]
        + MASTERY_WEIGHT.get(state.mastery, 0)
        + min(30, overdue * 2)
        + min(15, state.lapses * 3)
        + min(10, outdegree)
        - min(6, state.streak)
    )


def _prereq_satisfied(cid: int, states: dict[int, CardState], planned_new: set[int]) -> bool:
    return MASTERY_ORDER[states[cid].mastery] >= 1 or cid in planned_new


def _ready_unseen(cards: dict[int, CardMeta], states: dict[int, CardState], planned_new: set[int]) -> list[CardMeta]:
    ready = []
    for cid, meta in cards.items():
        if states[cid].mastery != 'U' or cid in planned_new:
            continue
        if all(_prereq_satisfied(p, states, planned_new) for p in meta.prereqs):
            ready.append(meta)
    outdeg = _outdegree(cards)
    order = {'S': 0, 'R': 1, 'A': 2, 'B': 3, 'C': 4}
    ready.sort(key=lambda m: (order[m.core], -outdeg[m.card_id], m.card_id))
    return ready


def build_plan(cards: dict[int, CardMeta], states: dict[int, CardState], minutes: int, today: date | None = None) -> list[PlanItem]:
    if minutes <= 0:
        return []
    today = today or date.today()
    outdeg = _outdegree(cards)

    due: list[tuple[float, int]] = []
    for cid, st in states.items():
        if st.mastery == 'U' or st.due_date is None or st.due_date > today:
            continue
        due.append((review_score(cards[cid], st, today, outdeg[cid]), cid))
    due.sort(reverse=True)

    weak_due = sum(1 for _, cid in due if states[cid].mastery in {'M0', 'M1'})
    freeze_new = weak_due >= max(2, minutes // 5)

    plan: list[PlanItem] = []
    spent = 0
    selected_due: set[int] = set()

    # 短时段优先一个最高价值节点；更长时段才给新卡留空间。
    potential_new = 0 if freeze_new else (1 if minutes < 10 else min(4, max(1, (minutes + 6) // 7)))
    if due:
        potential_new = 0 if minutes < 10 else max(0, minutes // 10)
    review_budget = minutes if freeze_new or potential_new == 0 else (0 if not due else max(1, int(minutes * 0.7)))

    for score, cid in due:
        cost = COST_MINUTES[states[cid].mastery]
        if spent + cost > review_budget:
            continue
        meta = cards[cid]
        plan.append(PlanItem(cid, meta.title, 'review', states[cid].mastery, score, cost))
        selected_due.add(cid)
        spent += cost

    if not freeze_new and spent < minutes:
        planned_new: set[int] = set()
        for _ in range(potential_new):
            ready = _ready_unseen(cards, states, planned_new)
            if not ready:
                break
            meta = ready[0]
            cost = COST_MINUTES['U']
            if spent + cost > minutes:
                break
            plan.append(PlanItem(meta.card_id, meta.title, 'new', 'U', CORE_WEIGHT[meta.core] + min(10, outdeg[meta.card_id]), cost))
            planned_new.add(meta.card_id)
            spent += cost

    for score, cid in due:
        if cid in selected_due:
            continue
        cost = COST_MINUTES[states[cid].mastery]
        if spent + cost > minutes:
            continue
        meta = cards[cid]
        plan.append(PlanItem(cid, meta.title, 'review', states[cid].mastery, score, cost))
        selected_due.add(cid)
        spent += cost

    return plan


def print_plan(plan: Iterable[PlanItem]) -> None:
    items = list(plan)
    if not items:
        print('今天没有需要安排的卡，或者时间预算太小。')
        return
    total = 0
    for i, item in enumerate(items, 1):
        total += item.minutes
        label = '新卡' if item.kind == 'new' else '复习'
        print(f'{i:>2}. 卡 {item.card_id:03d} [{label}/{item.mastery}] {item.title}  ~{item.minutes} min')
    print(f'预计总时长：{total} min')


def _cmd_plan(args: argparse.Namespace) -> None:
    cards = load_metadata(args.meta)
    states = load_states(args.state, cards)
    print_plan(build_plan(cards, states, args.minutes))


def _cmd_record(args: argparse.Namespace) -> None:
    cards = load_metadata(args.meta)
    states = load_states(args.state, cards)
    cid = args.card
    if cid not in cards:
        raise SystemExit(f'不存在卡 {cid}')
    states[cid] = apply_result(states[cid], args.result, cards[cid].core)
    if args.note is not None:
        states[cid].note = args.note
    save_states(args.state, states)
    st = states[cid]
    print(f'卡 {cid:03d}: {st.mastery}, 下次 {st.due_date}, lapses={st.lapses}, streak={st.streak}')


def _cmd_info(args: argparse.Namespace) -> None:
    cards = load_metadata(args.meta)
    meta = cards.get(args.card)
    if not meta:
        raise SystemExit(f'不存在卡 {args.card}')
    print(f'卡 {meta.card_id:03d} {meta.title}')
    print(f'难度={meta.difficulty} 优先级={meta.core} 目标={meta.target} V10旧ID={meta.legacy_v10_id}')
    print('前置:', ', '.join(map(str, meta.prereqs)) or '—')
    print('回退:', ', '.join(map(str, meta.rollback)) or '—')
    print('后续:', ', '.join(map(str, meta.followups)) or '—')


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(description='Python 碎片化复习手册 · CPython 3.14.7 调度器')
    p.add_argument('--meta', type=Path, default=Path('python_cards_3.14.7.json'))
    p.add_argument('--state', type=Path, default=Path('python_review_state.csv'))
    sub = p.add_subparsers(dest='command', required=True)

    plan = sub.add_parser('plan', help='生成今天的复习队列')
    plan.add_argument('--minutes', type=int, default=10)
    plan.set_defaults(func=_cmd_plan)

    record = sub.add_parser('record', help='记录一次复习结果')
    record.add_argument('card', type=int)
    record.add_argument('result', type=int, choices=[0, 1, 2], help='0=错误, 1=部分正确, 2=通过当前层级挑战')
    record.add_argument('--note', default=None)
    record.set_defaults(func=_cmd_record)

    info = sub.add_parser('info', help='查看卡片依赖和回退路径')
    info.add_argument('card', type=int)
    info.set_defaults(func=_cmd_info)
    return p


def main() -> None:
    args = build_parser().parse_args()
    args.func(args)


if __name__ == '__main__':
    main()
