"""成就指标注册表。

这里定义"能测量什么"，后台定义"多少算达成"。管理员在后台看到的指标下拉框
就是 METRIC_REGISTRY 的 key 列表。加一个新维度必须改本文件并部署，
之后在该维度上加任意多条成就都是纯配置。

约定：指标从未产生过有效值时，recompute 返回 None，调用方删除该 key。
metrics 字典里 key 不存在 == 未达标，判定时直接跳过。
"""

import logging

from django.db.models import Count, Q
from django.utils import timezone

from submission.models import JudgeStatus, Submission, is_accepted
from utils.constants import Difficulty

logger = logging.getLogger(__name__)

METRIC_REGISTRY = {}
META_METRICS = set()

# AC 口径与全项目一致：AST_CHECK_FAILED（代码结构检查未通过但测试点全过）也算通过。
# 见 submission/models.py 的 is_accepted()。ORM 过滤用这个常量，标量比较用 is_accepted()。
ACCEPTED_RESULTS = (JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED)


class BaseMetric:
    key = ""
    name = ""
    help_text = ""

    def on_submission(self, metrics, sub, ctx):
        """判题完成后增量更新 metrics（原地修改）。"""
        raise NotImplementedError

    def recompute(self, user):
        """全量重算。返回 None 表示该用户此指标无有效值。"""
        raise NotImplementedError

    def recompute_state(self, user):
        """重算时一并重建增量所需的辅助键，返回 {key: value}。

        默认无辅助状态。凡是 on_submission 依赖 `_` 前缀辅助键的指标都必须重写
        本方法：否则全量重算把辅助键丢掉后，下一次判题会从零重新累积，指标当场
        回退（active_days 从 45 掉回 1），且要等到下一次重算才恢复。
        """
        return {}


def metric(key, name, help_text="", meta=False):
    def deco(cls):
        cls.key = key
        cls.name = name
        cls.help_text = help_text
        METRIC_REGISTRY[key] = cls()
        if meta:
            META_METRICS.add(key)
        return cls

    return deco


def _practice_submissions(user_id):
    """成就只统计平时练习的提交，比赛提交不计入。"""
    return Submission.objects.filter(user_id=user_id, contest_id__isnull=True)


def build_ctx(user_id, sub):
    """判题后预查一次，所有指标复用，避免每个指标各自查库。

    比赛提交不参与成就统计，此时返回 skip=True，调用方直接跳过整轮判定。
    """
    if sub.contest_id is not None:
        return {"skip": True}

    prior = _practice_submissions(user_id).filter(problem_id=sub.problem_id).exclude(id=sub.id)
    prior_stats = prior.aggregate(
        total=Count("id"),
        accepted=Count("id", filter=Q(result__in=ACCEPTED_RESULTS)),
    )
    local_now = timezone.localtime(sub.create_time)
    sub_is_accepted = is_accepted(sub.result)
    is_first_ac_of_problem = sub_is_accepted and prior_stats["accepted"] == 0

    # 难度只有首次 AC 时才用得上，其余情况不查这一次库——
    # 绝大多数提交都不是首次 AC，放在外面等于给每次判题白加一条 SQL
    difficulty = None
    if is_first_ac_of_problem:
        from problem.models import Problem

        difficulty = Problem.objects.filter(id=sub.problem_id).values_list("difficulty", flat=True).first()

    return {
        "skip": False,
        "is_accepted": sub_is_accepted,
        # 该题此前的提交次数与 AC 次数
        "prior_count": prior_stats["total"],
        "prior_accepted": prior_stats["accepted"],
        # 首次 AC 这道题（此前从未 AC 过）
        "is_first_ac_of_problem": is_first_ac_of_problem,
        # 一发入魂：此前无任何提交且本次 AC
        "is_first_try_ac": sub_is_accepted and prior_stats["total"] == 0,
        # 本题难度，仅首次 AC 时有值
        "problem_difficulty": difficulty,
        "local_date": local_now.date().isoformat(),
        "local_hour": local_now.hour,
    }


@metric("accepted_count", "AC 题目数", "去重后通过的题目数量（不含比赛）")
class AcceptedCount(BaseMetric):
    def on_submission(self, metrics, sub, ctx):
        if ctx["is_first_ac_of_problem"]:
            metrics["accepted_count"] = metrics.get("accepted_count", 0) + 1

    def recompute(self, user):
        return _practice_submissions(user.id).filter(result__in=ACCEPTED_RESULTS).order_by().values("problem_id").distinct().count()


class _DifficultyAcCount(BaseMetric):
    """按难度去重统计 AC 题数。子类只需指定 difficulty。

    增量靠 ctx["problem_difficulty"]，它只在首次 AC 时才有值——与
    is_first_ac_of_problem 是同一个条件，所以两者一起判即可。
    """

    difficulty = ""

    def on_submission(self, metrics, sub, ctx):
        if ctx["is_first_ac_of_problem"] and ctx["problem_difficulty"] == self.difficulty:
            metrics[self.key] = metrics.get(self.key, 0) + 1

    def recompute(self, user):
        return _practice_submissions(user.id).filter(result__in=ACCEPTED_RESULTS, problem__difficulty=self.difficulty).order_by().values("problem_id").distinct().count()


@metric("mid_ac_count", "中等题 AC 数", "去重后通过的中等难度题目数（不含比赛）")
class MidAcCount(_DifficultyAcCount):
    difficulty = Difficulty.MID


@metric("hard_ac_count", "困难题 AC 数", "去重后通过的困难题目数（不含比赛）")
class HardAcCount(_DifficultyAcCount):
    difficulty = Difficulty.HIGH


@metric("submission_count", "提交总数", "提交次数（不含比赛）")
class SubmissionCount(BaseMetric):
    def on_submission(self, metrics, sub, ctx):
        metrics["submission_count"] = metrics.get("submission_count", 0) + 1

    def recompute(self, user):
        return _practice_submissions(user.id).count()


@metric("active_days", "活跃天数", "有过提交的累计天数")
class ActiveDays(BaseMetric):
    def on_submission(self, metrics, sub, ctx):
        seen = metrics.get("_active_dates", [])
        if ctx["local_date"] not in seen:
            seen.append(ctx["local_date"])
            metrics["_active_dates"] = seen
            metrics["active_days"] = len(seen)

    def recompute(self, user):
        dates = {timezone.localtime(t).date().isoformat() for t in _practice_submissions(user.id).values_list("create_time", flat=True)}
        return len(dates)

    def recompute_state(self, user):
        dates = sorted({timezone.localtime(t).date().isoformat() for t in _practice_submissions(user.id).values_list("create_time", flat=True)})
        return {"_active_dates": dates}


@metric("max_ac_streak_days", "最长连续 AC 天数", "连续每天至少 AC 一题的最长天数")
class MaxAcStreakDays(BaseMetric):
    def on_submission(self, metrics, sub, ctx):
        if not ctx["is_accepted"]:
            return
        today = ctx["local_date"]
        last = metrics.get("_last_ac_date")
        if last == today:
            return
        current = metrics.get("_current_ac_streak", 0)
        if last and (timezone.datetime.fromisoformat(today) - timezone.datetime.fromisoformat(last)).days == 1:
            current += 1
        else:
            current = 1
        metrics["_last_ac_date"] = today
        metrics["_current_ac_streak"] = current
        metrics["max_ac_streak_days"] = max(metrics.get("max_ac_streak_days", 0), current)

    def recompute(self, user):
        dates = sorted({timezone.localtime(t).date() for t in _practice_submissions(user.id).filter(result__in=ACCEPTED_RESULTS).values_list("create_time", flat=True)})
        if not dates:
            return None
        best = current = 1
        for prev, cur in zip(dates, dates[1:]):
            current = current + 1 if (cur - prev).days == 1 else 1
            best = max(best, current)
        return best

    def recompute_state(self, user):
        dates = sorted({timezone.localtime(t).date() for t in _practice_submissions(user.id).filter(result__in=ACCEPTED_RESULTS).values_list("create_time", flat=True)})
        if not dates:
            return {}
        current = 1
        for prev, cur in zip(dates, dates[1:]):
            current = current + 1 if (cur - prev).days == 1 else 1
        return {"_last_ac_date": dates[-1].isoformat(), "_current_ac_streak": current}


@metric("languages_used", "使用语言数", "用过多少种编程语言")
class LanguagesUsed(BaseMetric):
    def on_submission(self, metrics, sub, ctx):
        seen = metrics.get("_languages", [])
        if sub.language not in seen:
            seen.append(sub.language)
            metrics["_languages"] = seen
            metrics["languages_used"] = len(seen)

    def recompute(self, user):
        return _practice_submissions(user.id).order_by().values("language").distinct().count()

    def recompute_state(self, user):
        # order_by() 不能省：Submission.Meta 有默认排序 ("-create_time",)，
        # Django 会把排序字段并入 DISTINCT，于是每条提交各成一行——
        # 实测某用户返回 659 条而不是 5 种语言。
        # recompute 侥幸正确只是因为 .count() 会清掉排序，不能依赖这一点。
        return {"_languages": list(_practice_submissions(user.id).order_by().values_list("language", flat=True).distinct())}


@metric("contest_joined", "参赛场次", "参加过的比赛数量（本指标是比赛维度，不受比赛提交不计入的限制）")
class ContestJoined(BaseMetric):
    def on_submission(self, metrics, sub, ctx):
        # 比赛提交在 build_ctx 就被跳过，本指标只走 recompute
        return

    def recompute(self, user):
        return Submission.objects.filter(user_id=user.id, contest_id__isnull=False).order_by().values("contest_id").distinct().count()


@metric("badge_count", "题单奖章数", "获得的题单奖章数量")
class BadgeCount(BaseMetric):
    def on_submission(self, metrics, sub, ctx):
        # 奖章由题单流程颁发，本指标只走 recompute，见 Task 7
        return

    def recompute(self, user):
        from problemset.models import UserBadge

        return UserBadge.objects.filter(user=user).count()


@metric("problemset_completed", "完成题单数", "完成的题单数量")
class ProblemSetCompleted(BaseMetric):
    def on_submission(self, metrics, sub, ctx):
        return

    def recompute(self, user):
        from problemset.models import ProblemSetProgress

        return ProblemSetProgress.objects.filter(user=user, complete_time__isnull=False).count()


@metric("first_try_ac_count", "一发入魂次数", "首次提交即通过的次数")
class FirstTryAcCount(BaseMetric):
    def on_submission(self, metrics, sub, ctx):
        if ctx["is_first_try_ac"]:
            metrics["first_try_ac_count"] = metrics.get("first_try_ac_count", 0) + 1

    def recompute(self, user):
        count = 0
        seen = set()
        for s in _practice_submissions(user.id).order_by("create_time").values("problem_id", "result"):
            if s["problem_id"] in seen:
                continue
            seen.add(s["problem_id"])
            if is_accepted(s["result"]):
                count += 1
        return count


@metric("midnight_submissions", "凌晨提交次数", "0:00–5:00 之间的提交次数")
class MidnightSubmissions(BaseMetric):
    def on_submission(self, metrics, sub, ctx):
        if 0 <= ctx["local_hour"] < 5:
            metrics["midnight_submissions"] = metrics.get("midnight_submissions", 0) + 1

    def recompute(self, user):
        return sum(1 for t in _practice_submissions(user.id).values_list("create_time", flat=True) if 0 <= timezone.localtime(t).hour < 5)


@metric("early_bird_submissions", "早起提交次数", "5:00–7:00 之间的提交次数")
class EarlyBirdSubmissions(BaseMetric):
    """与 midnight_submissions 对称的作息维度。5 点是两者的分界，不重叠。"""

    def on_submission(self, metrics, sub, ctx):
        if 5 <= ctx["local_hour"] < 7:
            metrics["early_bird_submissions"] = metrics.get("early_bird_submissions", 0) + 1

    def recompute(self, user):
        return sum(1 for t in _practice_submissions(user.id).values_list("create_time", flat=True) if 5 <= timezone.localtime(t).hour < 7)


@metric("compile_error_count", "编译错误次数", "累计编译错误的次数")
class CompileErrorCount(BaseMetric):
    def on_submission(self, metrics, sub, ctx):
        if sub.result == JudgeStatus.COMPILE_ERROR:
            metrics["compile_error_count"] = metrics.get("compile_error_count", 0) + 1

    def recompute(self, user):
        return _practice_submissions(user.id).filter(result=JudgeStatus.COMPILE_ERROR).count()


@metric("max_wa_before_ac", "屡败屡战", "单题失败最多多少次后终于通过")
class MaxWaBeforeAc(BaseMetric):
    def on_submission(self, metrics, sub, ctx):
        if ctx["is_first_ac_of_problem"]:
            metrics["max_wa_before_ac"] = max(metrics.get("max_wa_before_ac", 0), ctx["prior_count"])

    def recompute(self, user):
        best = None
        attempts = {}
        for s in _practice_submissions(user.id).order_by("create_time").values("problem_id", "result"):
            pid = s["problem_id"]
            if pid in attempts and attempts[pid] is None:
                continue
            if is_accepted(s["result"]):
                best = max(best or 0, attempts.get(pid, 0))
                attempts[pid] = None
            else:
                attempts[pid] = attempts.get(pid, 0) + 1
        return best


@metric("max_ac_in_one_day", "单日最多 AC", "一天之内最多通过多少题")
class MaxAcInOneDay(BaseMetric):
    def on_submission(self, metrics, sub, ctx):
        if not ctx["is_first_ac_of_problem"]:
            return
        counts = metrics.get("_ac_per_day", {})
        counts[ctx["local_date"]] = counts.get(ctx["local_date"], 0) + 1
        metrics["_ac_per_day"] = counts
        metrics["max_ac_in_one_day"] = max(counts.values())

    def recompute(self, user):
        counts = {}
        seen = set()
        for s in _practice_submissions(user.id).filter(result__in=ACCEPTED_RESULTS).order_by("create_time").values("problem_id", "create_time"):
            if s["problem_id"] in seen:
                continue
            seen.add(s["problem_id"])
            day = timezone.localtime(s["create_time"]).date().isoformat()
            counts[day] = counts.get(day, 0) + 1
        return max(counts.values()) if counts else None

    def recompute_state(self, user):
        counts = {}
        seen = set()
        for s in _practice_submissions(user.id).filter(result__in=ACCEPTED_RESULTS).order_by("create_time").values("problem_id", "create_time"):
            if s["problem_id"] in seen:
                continue
            seen.add(s["problem_id"])
            day = timezone.localtime(s["create_time"]).date().isoformat()
            counts[day] = counts.get(day, 0) + 1
        return {"_ac_per_day": counts}


# 曾经这里有 min_ac_code_chars（最短 AC 代码）。线上实测 1314 个用户的分布，
# 最小值 8、p5=10：有道题 8 个字符就能通过，于是它测的是"谁做过那道水题"
# 而不是"谁写得简洁"，配不出有意义的成就，2026-08-05 删除。
# 要重新引入，得先按题目难度加权，或排除掉那类水题。


@metric("max_code_lines", "最长代码行数", "提交过的最长代码有多少行")
class MaxCodeLines(BaseMetric):
    def on_submission(self, metrics, sub, ctx):
        lines = len(sub.code.splitlines())
        metrics["max_code_lines"] = max(metrics.get("max_code_lines", 0), lines)

    def recompute(self, user):
        counts = [len(c.splitlines()) for c in _practice_submissions(user.id).values_list("code", flat=True)]
        return max(counts) if counts else None


@metric("achievement_unlocked_count", "已解锁成就数", "已解锁的成就数量（不含白金档）", meta=True)
class AchievementUnlockedCount(BaseMetric):
    """自引用指标：解锁成就会改变它。

    因此判定流程限定为最多两轮（见 checker.py），且口径排除白金档自身，
    避免「集齐 N 个成就」这类白金奖杯把自己算进分子。
    """

    def on_submission(self, metrics, sub, ctx):
        # 由 checker 在第一轮解锁后显式重算，不参与增量更新
        return

    def recompute(self, user):
        from achievement.models import Rarity, UserAchievement

        return UserAchievement.objects.filter(user=user).exclude(achievement__rarity=Rarity.PLATINUM).count()
