import math
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import Optional

from sqlalchemy.orm import Session

from models import QuizRecord, User, Word


def parse_iso(s: str) -> datetime:
    s = s.replace("Z", "+00:00")
    try:
        return datetime.fromisoformat(s)
    except ValueError:
        return datetime.strptime(s[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)


def word_en(w: Word) -> str:
    return w.target_text if w.source_lang == "zh" else w.source_text


def word_zh(w: Word) -> str:
    return w.source_text if w.source_lang == "zh" else w.target_text


def stability_hours(word: Word) -> float:
    """记忆稳定性（小时），随练习增强。"""
    base = 24.0
    streak = min(word.consecutive_correct_count, 6)
    mastery_factor = 1 + word.mastery_score / 200
    return base * (1.6**streak) * mastery_factor


def retention_percent(hours_since: float, stability_h: float) -> float:
    """艾宾浩斯型遗忘：R = 100 * e^(-t/S)"""
    s = max(stability_h, 6.0)
    return max(0.0, min(100.0, 100 * math.exp(-hours_since / s)))


def mastery_at_time(word: Word, records: list[QuizRecord], at: datetime) -> float:
    correct = 0
    wrong = 0
    for r in records:
        if r.word_id != word.id:
            continue
        if parse_iso(r.created_at) > at:
            break
        if r.is_correct:
            correct += 1
        else:
            wrong += 1
    total = correct + wrong
    if total == 0:
        return float(word.mastery_score) if word.last_reviewed_at else 0.0
    return round(correct / total * 100, 1)


class MemoryVisualService:
    def get_visualization(
        self, db: Session, user: User, horizon_days: int = 30, book_id: int = 0
    ) -> dict:
        words = (
            db.query(Word)
            .filter(Word.user_id == user.id, Word.book_id == book_id, Word.status != "locked")
            .all()
        )
        word_ids = {w.id for w in words}
        records = [
            r
            for r in db.query(QuizRecord)
            .filter(QuizRecord.user_id == user.id)
            .order_by(QuizRecord.created_at.asc())
            .all()
            if r.word_id in word_ids
        ]
        records_by_word: dict[int, list[QuizRecord]] = defaultdict(list)
        for r in records:
            records_by_word[r.word_id].append(r)

        now = datetime.now(timezone.utc)
        horizon_days = max(7, min(horizon_days, 60))

        if not words:
            return {
                "curve_points": [],
                "words": [],
                "graph": {"nodes": [], "links": []},
            }

        earliest = min(parse_iso(w.created_at) for w in words)
        span_days = max(
            1,
            int((now - earliest).total_seconds() // 86400) + 1,
        )
        horizon = min(horizon_days, span_days)

        curve_points = []
        for d in range(horizon + 1):
            t = earliest + timedelta(days=d)
            forgetting_vals: list[float] = []
            mastery_vals: list[float] = []
            risk_vals: list[float] = []

            for w in words:
                entered = parse_iso(w.created_at)
                if t < entered:
                    continue

                hours_after_enter = (t - entered).total_seconds() / 3600
                stab = stability_hours(w)
                wr = records_by_word.get(w.id, [])

                # 遗忘曲线：自加入词库起的记忆保留率
                reviews_before = sum(1 for r in wr if parse_iso(r.created_at) <= t)
                boost = 1 + reviews_before * 0.12
                forgetting_vals.append(
                    retention_percent(hours_after_enter, stab * boost)
                )

                # 熟练曲线：截至该日的掌握度
                mastery_vals.append(mastery_at_time(w, wr, t))

                # 可能遗忘：从该日视角若不再复习的预测保留率
                last_at = entered
                if w.last_reviewed_at:
                    lr = parse_iso(w.last_reviewed_at)
                    if lr <= t:
                        last_at = lr
                hours_since_review = (t - last_at).total_seconds() / 3600
                risk_vals.append(retention_percent(hours_since_review, stab * 0.85))

            if not forgetting_vals:
                continue

            curve_points.append(
                {
                    "day_index": d,
                    "date": t.strftime("%Y-%m-%d"),
                    "forgetting": round(sum(forgetting_vals) / len(forgetting_vals), 1),
                    "mastery": round(sum(mastery_vals) / len(mastery_vals), 1),
                    "risk": round(sum(risk_vals) / len(risk_vals), 1),
                }
            )

        # 今日起未来 14 天风险预测
        future_risk = []
        for fd in range(15):
            t = now + timedelta(days=fd)
            vals = []
            for w in words:
                last_at = parse_iso(w.last_reviewed_at or w.created_at)
                hours = (t - last_at).total_seconds() / 3600
                vals.append(retention_percent(hours, stability_hours(w)))
            future_risk.append(
                {
                    "day_offset": fd,
                    "date": t.strftime("%Y-%m-%d"),
                    "risk": round(sum(vals) / len(vals), 1),
                }
            )

        word_summaries = []
        for w in words:
            stab_h = stability_hours(w)
            last_at = parse_iso(w.last_reviewed_at or w.created_at)
            hours_since = (now - last_at).total_seconds() / 3600
            word_summaries.append(
                {
                    "id": w.id,
                    "en": word_en(w),
                    "zh": word_zh(w),
                    "status": w.status,
                    "mastery_score": w.mastery_score,
                    "correct_count": w.correct_count,
                    "wrong_count": w.wrong_count,
                    "train_count": w.train_count,
                    "total_train_seconds": w.total_train_seconds,
                    "entered_at": w.created_at,
                    "retention_now": round(retention_percent(hours_since, stab_h), 1),
                    "risk_7d": round(
                        retention_percent(hours_since + 7 * 24, stab_h), 1
                    ),
                }
            )

        graph = self._build_graph(words, records)

        return {
            "curve_points": curve_points,
            "future_risk": future_risk,
            "words": word_summaries,
            "graph": graph,
        }

    def _build_graph(self, words: list[Word], records: list[QuizRecord]) -> dict:
        nodes = []
        id_set = set()
        for w in words:
            id_set.add(w.id)
            nodes.append(
                {
                    "id": str(w.id),
                    "label": word_en(w)[:16],
                    "zh": word_zh(w)[:8],
                    "status": w.status,
                    "mastery": w.mastery_score,
                    "entered_at": w.created_at,
                    "size": 8 + min(w.correct_count + w.wrong_count, 20),
                }
            )

        links = []
        seen_edges: set[tuple[str, str]] = set()

        def add_link(a: int, b: int, kind: str, strength: float = 0.5) -> None:
            if a == b or a not in id_set or b not in id_set:
                return
            key = (str(min(a, b)), str(max(a, b)))
            if key in seen_edges:
                return
            seen_edges.add(key)
            links.append(
                {
                    "source": str(a),
                    "target": str(b),
                    "kind": kind,
                    "strength": strength,
                }
            )

        # 同日练习关联
        by_day: dict[str, list[int]] = defaultdict(list)
        for r in records:
            by_day[r.created_at[:10]].append(r.word_id)
        for ids in by_day.values():
            unique = list(set(ids))
            for i in range(len(unique)):
                for j in range(i + 1, len(unique)):
                    add_link(unique[i], unique[j], "co_review", 0.7)

        # 相同学习状态
        by_status: dict[str, list[int]] = defaultdict(list)
        for w in words:
            by_status[w.status].append(w.id)
        for ids in by_status.values():
            for i in range(len(ids)):
                for j in range(i + 1, min(i + 4, len(ids))):  # 限制边数量
                    add_link(ids[i], ids[j], "status", 0.35)

        # 词形相近（英文前缀 / 包含关系）
        en_map = {w.id: word_en(w).lower() for w in words}
        ids = list(en_map.keys())
        for i in range(len(ids)):
            for j in range(i + 1, len(ids)):
                a, b = en_map[ids[i]], en_map[ids[j]]
                if len(a) >= 3 and len(b) >= 3:
                    if a[:3] == b[:3] or a in b or b in a:
                        add_link(ids[i], ids[j], "similar", 0.45)

        return {"nodes": nodes, "links": links[:120]}

    def build_word_curve_points(self, word: Word, records: list[QuizRecord]) -> list[dict]:
        entered = parse_iso(word.created_at)
        points: list[dict] = [
            {
                "date": entered.strftime("%Y-%m-%d"),
                "datetime": word.created_at,
                "forgetting": 100.0,
                "mastery": 0.0,
                "risk": 100.0,
                "wrong_count": 0,
                "train_count": 0,
                "train_seconds": 0,
                "is_correct": None,
            }
        ]
        if not records:
            return points

        correct = 0
        wrong = 0
        total_sec = 0
        last_review = entered

        for i, r in enumerate(records):
            t = parse_iso(r.created_at)
            if r.is_correct:
                correct += 1
            else:
                wrong += 1
            total_sec += r.duration_seconds or 0

            reviews = i + 1
            hours_enter = (t - entered).total_seconds() / 3600
            stab = stability_hours(word)
            forgetting = retention_percent(hours_enter, stab * (1 + reviews * 0.12))
            total = correct + wrong
            mastery = round(correct / total * 100, 1) if total else 0.0
            hours_since = (t - last_review).total_seconds() / 3600
            risk = retention_percent(hours_since, stab * 0.85)
            last_review = t

            points.append(
                {
                    "date": t.strftime("%Y-%m-%d"),
                    "datetime": r.created_at,
                    "forgetting": round(forgetting, 1),
                    "mastery": mastery,
                    "risk": round(risk, 1),
                    "wrong_count": wrong,
                    "train_count": i + 1,
                    "train_seconds": total_sec,
                    "is_correct": bool(r.is_correct),
                }
            )

        return points

    def build_word_future_risk(self, word: Word) -> list[dict]:
        now = datetime.now(timezone.utc)
        last_at = parse_iso(word.last_reviewed_at or word.created_at)
        hours_since = (now - last_at).total_seconds() / 3600
        stab = stability_hours(word)
        future = []
        for fd in range(15):
            t = now + timedelta(days=fd)
            hours = hours_since + fd * 24
            future.append(
                {
                    "day_offset": fd,
                    "date": t.strftime("%Y-%m-%d"),
                    "risk": round(retention_percent(hours, stab), 1),
                }
            )
        return future

    def get_word_memory(self, db: Session, user: User, word_id: int) -> dict:
        word = db.query(Word).filter(Word.id == word_id, Word.user_id == user.id).first()
        if not word:
            from fastapi import HTTPException

            raise HTTPException(status_code=404, detail="单词不存在")

        records = (
            db.query(QuizRecord)
            .filter(QuizRecord.user_id == user.id, QuizRecord.word_id == word.id)
            .order_by(QuizRecord.created_at.asc())
            .all()
        )
        return {
            "word_id": word.id,
            "en": word_en(word),
            "zh": word_zh(word),
            "correct_count": word.correct_count,
            "wrong_count": word.wrong_count,
            "train_count": word.train_count,
            "total_train_seconds": word.total_train_seconds,
            "curve_points": self.build_word_curve_points(word, records),
            "future_risk": self.build_word_future_risk(word),
        }


memory_visual_service = MemoryVisualService()
