import hashlib
import json
from datetime import datetime
from pathlib import Path

import joblib
import numpy as np
from sklearn.ensemble import RandomForestRegressor


GOAL_MAP = {
    "maintain": 0,
    "lose_fat": 1,
    "gain_muscle": 2,
    "keto": 3,
}

OCCUPATION_MAP = {
    "通用": 0,
    "student": 1,
    "office": 2,
    "teacher": 3,
    "developer": 4,
    "healthcare": 5,
    "fitness": 6,
    "manual": 7,
}


class RandomForestDietRecommender:
    def __init__(self, model_path: str):
        self.model_path = Path(model_path)
        self.model = None
        self.recipe_signature = None

    @staticmethod
    def _encode_goal(goal: str) -> int:
        return GOAL_MAP.get(goal or "maintain", 0)

    @staticmethod
    def _encode_occupation(occupation: str) -> int:
        occupation = occupation or "通用"
        if occupation in OCCUPATION_MAP:
            return OCCUPATION_MAP[occupation]
        return OCCUPATION_MAP["通用"]

    def _signature(self, recipes: list) -> str:
        raw = [
            {
                "id": item.id,
                "name": item.name,
                "calories": item.calories,
                "protein": item.protein,
                "fat": item.fat,
                "carbs": item.carbs,
                "fiber": item.fiber,
                "updated": item.updated_at.isoformat() if item.updated_at else "",
            }
            for item in recipes
        ]
        raw_json = json.dumps(raw, ensure_ascii=False, sort_keys=True)
        return hashlib.md5(raw_json.encode("utf-8")).hexdigest()

    @staticmethod
    def _daily_target_kcal(profile: dict) -> float:
        goal = profile.get("goal", "maintain")
        baseline = 1800 + float(profile.get("exercise_kcal", 0)) * 0.4
        if goal == "lose_fat":
            baseline *= 0.82
        elif goal == "gain_muscle":
            baseline *= 1.12
        elif goal == "keto":
            baseline *= 0.9
        return max(baseline, 1200)

    @staticmethod
    def _heuristic_score(profile: dict, recipe) -> float:
        goal = profile.get("goal", "maintain")
        daily_target = RandomForestDietRecommender._daily_target_kcal(profile)
        target_per_meal = daily_target / 3

        cal_gap_ratio = abs(recipe.calories - target_per_meal) / max(target_per_meal, 1)
        protein_ratio = recipe.protein / max(recipe.calories, 1)
        carbs_ratio = recipe.carbs / max(recipe.calories, 1)
        fat_ratio = recipe.fat / max(recipe.calories, 1)

        score = 100.0
        score -= min(cal_gap_ratio * 55, 50)

        if goal == "lose_fat":
            score += min(recipe.protein * 0.6, 18)
            score -= max((recipe.fat - 20) * 0.7, 0)
            score -= max((recipe.carbs - 55) * 0.3, 0)
        elif goal == "gain_muscle":
            score += min(recipe.protein * 0.8, 26)
            score += min(recipe.carbs * 0.2, 10)
        elif goal == "keto":
            score += min(recipe.fat * 0.4, 18)
            score -= max(recipe.carbs - 30, 0) * 0.8
        else:
            score += min(recipe.fiber * 1.2, 8)

        body_fat = float(profile.get("body_fat", 20))
        if body_fat > 28:
            score -= max(recipe.calories - 520, 0) * 0.03

        intake_kcal = float(profile.get("intake_kcal", 1800))
        if intake_kcal > daily_target:
            score -= max(recipe.calories - 460, 0) * 0.02

        score += np.clip((protein_ratio - 0.12) * 100, -8, 8)
        score += np.clip((0.08 - carbs_ratio) * 80 if goal == "keto" else 0, -6, 6)
        score += np.clip((0.25 - fat_ratio) * 30 if goal == "lose_fat" else 0, -5, 5)

        return float(np.clip(score, 1, 100))

    def _build_feature(self, profile: dict, recipe) -> list:
        return [
            float(profile.get("weight", 65)),
            float(profile.get("body_fat", 20)),
            float(profile.get("exercise_kcal", 300)),
            float(profile.get("intake_kcal", 1800)),
            float(profile.get("age", 25)),
            float(profile.get("height_cm", 170)),
            float(self._encode_goal(profile.get("goal", "maintain"))),
            float(self._encode_occupation(profile.get("occupation", "通用"))),
            float(recipe.calories),
            float(recipe.protein),
            float(recipe.fat),
            float(recipe.carbs),
            float(recipe.fiber or 0),
        ]

    def _sample_profiles(self, n: int = 600) -> list:
        rng = np.random.default_rng(2026)
        goals = list(GOAL_MAP.keys())
        occupations = list(OCCUPATION_MAP.keys())

        profiles = []
        for _ in range(n):
            goal = goals[int(rng.integers(0, len(goals)))]
            occupation = occupations[int(rng.integers(0, len(occupations)))]
            profiles.append(
                {
                    "weight": float(rng.uniform(45, 100)),
                    "body_fat": float(rng.uniform(10, 38)),
                    "exercise_kcal": float(rng.uniform(50, 850)),
                    "intake_kcal": float(rng.uniform(1200, 3200)),
                    "age": float(rng.uniform(18, 55)),
                    "height_cm": float(rng.uniform(150, 190)),
                    "goal": goal,
                    "occupation": occupation,
                }
            )
        return profiles

    def train(self, recipes: list) -> None:
        if not recipes:
            raise ValueError("训练随机森林前至少需要 1 条食谱数据")

        x_rows = []
        y_rows = []
        sampled_profiles = self._sample_profiles()

        for profile in sampled_profiles:
            for recipe in recipes:
                x_rows.append(self._build_feature(profile, recipe))
                y_rows.append(self._heuristic_score(profile, recipe))

        x = np.array(x_rows)
        y = np.array(y_rows)

        model = RandomForestRegressor(
            n_estimators=240,
            random_state=2026,
            max_depth=12,
            min_samples_leaf=2,
            n_jobs=-1,
        )
        model.fit(x, y)

        self.model = model
        self.recipe_signature = self._signature(recipes)

        self.model_path.parent.mkdir(parents=True, exist_ok=True)
        joblib.dump(
            {
                "model": model,
                "recipe_signature": self.recipe_signature,
                "trained_at": datetime.utcnow().isoformat(),
            },
            self.model_path,
        )

    def load_or_train(self, recipes: list) -> None:
        current_signature = self._signature(recipes)
        if self.model_path.exists():
            payload = joblib.load(self.model_path)
            if payload.get("recipe_signature") == current_signature:
                self.model = payload["model"]
                self.recipe_signature = current_signature
                return

        self.train(recipes)

    @staticmethod
    def _build_reason(profile: dict, recipe, score: float) -> str:
        goal = profile.get("goal", "maintain")
        if goal == "lose_fat":
            return f"热量适中，蛋白质 {recipe.protein}g，适合减脂期控热量和保肌。"
        if goal == "gain_muscle":
            return f"蛋白质与碳水配置较高，适合增肌训练后的恢复。"
        if goal == "keto":
            return f"碳水 {recipe.carbs}g，偏低碳结构，适合生酮期参考。"
        if score > 80:
            return "营养均衡度高，适合作为日常轻食搭配。"
        return "综合营养结构较均衡，可作为个性化备选方案。"

    def recommend(self, profile: dict, recipes: list, top_k: int = 5) -> list:
        if not recipes:
            return []

        self.load_or_train(recipes)
        x = np.array([self._build_feature(profile, recipe) for recipe in recipes])
        pred_scores = self.model.predict(x)

        result = []
        for recipe, score in zip(recipes, pred_scores):
            row = recipe.to_dict()
            row["rf_score"] = round(float(score), 2)
            row["reason"] = self._build_reason(profile, recipe, float(score))
            result.append(row)

        result.sort(key=lambda item: item["rf_score"], reverse=True)
        return result[:top_k]


def merge_profile_with_history(base_profile: dict, history: list) -> dict:
    if not history:
        return base_profile

    weights = [item.weight for item in history]
    body_fats = [item.body_fat for item in history]
    exercise = [item.exercise_kcal for item in history]
    intake = [item.intake_kcal for item in history]

    merged = dict(base_profile)
    merged["weight"] = float(np.mean(weights))
    merged["body_fat"] = float(np.mean(body_fats))
    merged["exercise_kcal"] = float(np.mean(exercise))
    merged["intake_kcal"] = float(np.mean(intake))
    return merged
