from __future__ import annotations

import hashlib
import json
import re
from collections import Counter
from dataclasses import dataclass, field
from datetime import datetime
from difflib import SequenceMatcher
from typing import Iterable

from sqlalchemy import and_, func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session

from app.models.chat import ChatMessage, ChatSession
from app.models.insight import QuestionInsightCleanedQuestion
from app.models.logs import AiRequestLog
from app.models.user import User


CLEANER_VERSION = "v1"

LOW_VALUE_EXACT = {
    "你好",
    "您好",
    "老师好",
    "在吗",
    "好的",
    "好",
    "嗯",
    "嗯嗯",
    "收到",
    "谢谢",
    "感谢",
    "明白",
    "可以",
    "ok",
    "OK",
}

COURTESY_PREFIXES = (
    "老师你好",
    "老师您好",
    "老师好",
    "你好",
    "您好",
    "请问一下",
    "请问",
    "想问一下",
    "麻烦问下",
    "我想问一下",
    "我想问问",
)

COURTESY_SUFFIXES = (
    "谢谢老师",
    "谢谢",
    "感谢老师",
    "感谢",
    "麻烦老师",
    "辛苦老师",
)

SYNONYM_RULES = (
    (re.compile(r"(作业|练习|功课|课后任务|课后练习)"), "功课"),
    (re.compile(r"(回放|录播|视频回看|回看)"), "回放"),
    (re.compile(r"(会议链接|会议号|直播链接|上课链接|腾讯会议|飞书会议)"), "会议链接"),
    (re.compile(r"(助教|助理|班主任|辅导老师)"), "课程助理"),
    (re.compile(r"(上课|直播|带练|带领练习)"), "上课安排"),
    (re.compile(r"(都有哪些|有哪些|都有什么|有什么|全部|所有)"), "有哪些"),
    (re.compile(r"(怎么做|如何做|咋做|具体步骤|操作步骤|怎么操作|具体操作)"), "怎么做"),
    (re.compile(r"(是什么|什么意思|啥意思|定义|区别)"), "是什么"),
)

NOISE_PATTERN = re.compile(r"[\s\u3000,，。！？!?；;：:、“”\"'‘’（）()\[\]【】<>《》]+")
QUESTION_SPLIT_PATTERN = re.compile(
    r"(?:\n+|[？?]\s*|(?:^|\n|\s)[0-9一二三四五六七八九十]+[、.．]\s*)"
)


@dataclass
class CleanedQuestion:
    raw: str
    text: str
    normalized: str
    user_id: int
    user_name: str
    user_phone: str
    session_id: int
    message_id: int
    created_at: datetime
    tokens: set[str]


@dataclass
class QuestionCluster:
    title: str
    normalized: str
    tokens: set[str]
    questions: list[CleanedQuestion] = field(default_factory=list)

    def add(self, question: CleanedQuestion) -> None:
        self.questions.append(question)
        if len(question.normalized) < len(self.normalized) or _looks_more_question_like(question.text, self.title):
            self.title = question.text
            self.normalized = question.normalized
        self.tokens = _merge_tokens(self.tokens, question.tokens)


class QuestionInsightService:
    @staticmethod
    def refresh(
        db: Session,
        *,
        date_from: datetime | None = None,
        date_to: datetime | None = None,
        max_messages: int = 5000,
    ) -> dict:
        """增量清洗尚未处理的用户消息，并把结果写入持久化清洗表。"""

        messages = _load_unprocessed_user_messages(
            db,
            date_from=date_from,
            date_to=date_to,
            limit=max_messages,
        )
        processed_messages = 0
        accepted_questions = 0
        filtered_messages = 0
        concurrent_skips = 0

        for row in messages:
            message = row[0]
            cleaned, filtered_count = _clean_messages([row])
            records = _cleaned_records(message, cleaned, filtered_count)
            try:
                # 多个管理员同时刷新时，唯一约束负责去重；单条消息冲突不会回滚整批。
                with db.begin_nested():
                    db.add_all(records)
                    db.flush()
            except IntegrityError:
                concurrent_skips += 1
                continue
            processed_messages += 1
            accepted_questions += len(cleaned)
            filtered_messages += filtered_count

        has_more = bool(
            _load_unprocessed_user_messages(
                db,
                date_from=date_from,
                date_to=date_to,
                limit=1,
            )
        )
        return {
            "processedMessages": processed_messages,
            "acceptedQuestions": accepted_questions,
            "filteredMessages": filtered_messages,
            "concurrentSkips": concurrent_skips,
            "hasMore": has_more,
            "cleanerVersion": CLEANER_VERSION,
        }

    @staticmethod
    def summarize(
        db: Session,
        *,
        date_from: datetime | None = None,
        date_to: datetime | None = None,
        min_count: int = 2,
        page: int = 1,
        page_size: int = 20,
        max_messages: int = 5000,
    ) -> dict:
        persisted_items = _load_persisted_cleaned_questions(
            db,
            date_from=date_from,
            date_to=date_to,
            limit=max_messages,
        )
        cleaned = [_question_from_persisted(item) for item in persisted_items if item.accepted]
        source_message_ids = {item.message_id for item in persisted_items}
        accepted_message_ids = {item.message_id for item in persisted_items if item.accepted}
        filtered_count = len(source_message_ids - accepted_message_ids)
        clusters = _cluster_questions(cleaned)
        ai_logs = _load_ai_logs(db, date_from=date_from, date_to=date_to, limit=max_messages)
        visible_clusters = [cluster for cluster in clusters if len(cluster.questions) >= min_count]
        visible_clusters.sort(key=lambda item: (len(item.questions), item.questions[-1].created_at), reverse=True)

        total = len(visible_clusters)
        offset = (page - 1) * page_size
        page_clusters = visible_clusters[offset : offset + page_size]
        message_contents, users = _load_sample_context(db, page_clusters)

        return {
            "range": {
                "dateFrom": date_from,
                "dateTo": date_to,
                "maxMessages": max_messages,
            },
            "summary": {
                "scannedMessages": len(source_message_ids),
                "cleanedQuestions": len(cleaned),
                "filteredMessages": filtered_count,
                "clusterCount": len(clusters),
                "visibleClusterCount": total,
                "minCount": min_count,
                "cleanerVersion": CLEANER_VERSION,
            },
            "items": [
                _cluster_dict(
                    index + offset + 1,
                    cluster,
                    ai_logs,
                    message_contents=message_contents,
                    users=users,
                )
                for index, cluster in enumerate(page_clusters)
            ],
            "total": total,
            "page": page,
            "pageSize": page_size,
        }


def _load_unprocessed_user_messages(
    db: Session,
    *,
    date_from: datetime | None,
    date_to: datetime | None,
    limit: int,
) -> list[tuple[ChatMessage, ChatSession | None, User | None]]:
    query = (
        select(ChatMessage, ChatSession, User)
        .outerjoin(
            QuestionInsightCleanedQuestion,
            and_(
                QuestionInsightCleanedQuestion.message_id == ChatMessage.id,
                QuestionInsightCleanedQuestion.cleaner_version == CLEANER_VERSION,
            ),
        )
        .join(ChatSession, ChatSession.id == ChatMessage.session_id, isouter=True)
        .join(User, User.id == ChatMessage.user_id, isouter=True)
        .where(
            ChatMessage.role == "user",
            QuestionInsightCleanedQuestion.id.is_(None),
        )
    )
    if date_from is not None:
        query = query.where(ChatMessage.created_at >= date_from.replace(tzinfo=None))
    if date_to is not None:
        query = query.where(ChatMessage.created_at <= date_to.replace(tzinfo=None))
    return list(
        db.execute(
            query.order_by(ChatMessage.created_at.desc(), ChatMessage.id.desc()).limit(limit)
        ).all()
    )


def _load_persisted_cleaned_questions(
    db: Session,
    *,
    date_from: datetime | None,
    date_to: datetime | None,
    limit: int,
) -> list[QuestionInsightCleanedQuestion]:
    range_filters = [QuestionInsightCleanedQuestion.cleaner_version == CLEANER_VERSION]
    if date_from is not None:
        range_filters.append(QuestionInsightCleanedQuestion.source_created_at >= date_from.replace(tzinfo=None))
    if date_to is not None:
        range_filters.append(QuestionInsightCleanedQuestion.source_created_at <= date_to.replace(tzinfo=None))

    latest_messages = (
        select(
            QuestionInsightCleanedQuestion.message_id.label("message_id"),
            func.max(QuestionInsightCleanedQuestion.source_created_at).label("latest_at"),
        )
        .where(*range_filters)
        .group_by(QuestionInsightCleanedQuestion.message_id)
        .order_by(
            func.max(QuestionInsightCleanedQuestion.source_created_at).desc(),
            QuestionInsightCleanedQuestion.message_id.desc(),
        )
        .limit(limit)
        .subquery()
    )
    return list(
        db.scalars(
            select(QuestionInsightCleanedQuestion)
            .join(
                latest_messages,
                QuestionInsightCleanedQuestion.message_id == latest_messages.c.message_id,
            )
            .where(QuestionInsightCleanedQuestion.cleaner_version == CLEANER_VERSION)
            .order_by(
                QuestionInsightCleanedQuestion.source_created_at.desc(),
                QuestionInsightCleanedQuestion.message_id.desc(),
                QuestionInsightCleanedQuestion.part_index.asc(),
            )
        ).all()
    )


def _load_ai_logs(
    db: Session,
    *,
    date_from: datetime | None,
    date_to: datetime | None,
    limit: int,
) -> list[AiRequestLog]:
    query = select(AiRequestLog)
    if date_from is not None:
        query = query.where(AiRequestLog.created_at >= date_from.replace(tzinfo=None))
    if date_to is not None:
        query = query.where(AiRequestLog.created_at <= date_to.replace(tzinfo=None))
    return list(db.scalars(query.order_by(AiRequestLog.created_at.desc(), AiRequestLog.id.desc()).limit(limit)).all())


def _clean_messages(messages: Iterable[tuple[ChatMessage, ChatSession | None, User | None]]) -> tuple[list[CleanedQuestion], int]:
    cleaned: list[CleanedQuestion] = []
    filtered_count = 0
    seen_parts: set[tuple[int, str]] = set()
    for message, _session, user in messages:
        parts = _split_questions(message.content)
        accepted = 0
        for part in parts:
            text = _clean_text(part)
            if _is_low_value(text):
                continue
            normalized = _normalize_question(text)
            if len(normalized) < 3:
                continue
            dedupe_key = (message.id, normalized)
            if dedupe_key in seen_parts:
                continue
            seen_parts.add(dedupe_key)
            accepted += 1
            cleaned.append(
                CleanedQuestion(
                    raw=message.content,
                    text=text,
                    normalized=normalized,
                    user_id=message.user_id,
                    user_name=user.name if user else "",
                    user_phone=user.phone if user else "",
                    session_id=message.session_id,
                    message_id=message.id,
                    created_at=message.created_at,
                    tokens=_tokens(normalized),
                )
            )
        if accepted == 0:
            filtered_count += 1
    return cleaned, filtered_count


def _cleaned_records(
    message: ChatMessage,
    cleaned: list[CleanedQuestion],
    filtered_count: int,
) -> list[QuestionInsightCleanedQuestion]:
    source_hash = hashlib.sha256((message.content or "").encode("utf-8")).hexdigest()
    if filtered_count:
        return [
            QuestionInsightCleanedQuestion(
                message_id=message.id,
                session_id=message.session_id,
                user_id=message.user_id,
                part_index=-1,
                cleaner_version=CLEANER_VERSION,
                source_hash=source_hash,
                cleaned_text="",
                normalized_text="",
                category="filtered",
                tokens_json="[]",
                accepted=0,
                filtered_reason="低价值或无有效问题",
                source_created_at=message.created_at,
            )
        ]

    records: list[QuestionInsightCleanedQuestion] = []
    for part_index, question in enumerate(cleaned):
        category, _category_label = _classify_text(f"{question.text}{question.normalized}")
        records.append(
            QuestionInsightCleanedQuestion(
                message_id=message.id,
                session_id=message.session_id,
                user_id=message.user_id,
                part_index=part_index,
                cleaner_version=CLEANER_VERSION,
                source_hash=source_hash,
                cleaned_text=question.text,
                normalized_text=question.normalized,
                category=category,
                tokens_json=json.dumps(sorted(question.tokens), ensure_ascii=False),
                accepted=1,
                filtered_reason=None,
                source_created_at=message.created_at,
            )
        )
    return records


def _question_from_persisted(item: QuestionInsightCleanedQuestion) -> CleanedQuestion:
    try:
        decoded_tokens = json.loads(item.tokens_json or "[]")
    except (TypeError, ValueError, json.JSONDecodeError):
        decoded_tokens = []
    tokens = {
        str(token)
        for token in decoded_tokens
        if isinstance(token, str) and token
    }
    if not tokens:
        tokens = _tokens(item.normalized_text)
    return CleanedQuestion(
        raw="",
        text=item.cleaned_text,
        normalized=item.normalized_text,
        user_id=item.user_id,
        user_name="",
        user_phone="",
        session_id=item.session_id,
        message_id=item.message_id,
        created_at=item.source_created_at,
        tokens=tokens,
    )


def _split_questions(content: str) -> list[str]:
    text = (content or "").strip()
    if not text:
        return []
    rough_parts = [part.strip() for part in QUESTION_SPLIT_PATTERN.split(text) if part.strip()]
    if len(rough_parts) <= 1:
        return [text]
    merged: list[str] = []
    for part in rough_parts:
        if len(part) <= 2 and merged:
            merged[-1] = f"{merged[-1]} {part}"
        else:
            merged.append(part)
    return merged


def _clean_text(text: str) -> str:
    value = re.sub(r"\s+", " ", text.strip())
    changed = True
    while changed:
        changed = False
        for prefix in COURTESY_PREFIXES:
            if value.startswith(prefix):
                value = value[len(prefix) :].lstrip(" ，,。:：")
                changed = True
        for suffix in COURTESY_SUFFIXES:
            if value.endswith(suffix):
                value = value[: -len(suffix)].rstrip(" ，,。:：")
                changed = True
    return value.strip()


def _is_low_value(text: str) -> bool:
    compact = NOISE_PATTERN.sub("", text)
    if not compact:
        return True
    if compact in LOW_VALUE_EXACT:
        return True
    if len(compact) <= 2:
        return True
    return len(compact) <= 6 and not any(marker in compact for marker in ("吗", "么", "哪", "谁", "怎么", "如何", "什么", "为啥", "为什么"))


def _normalize_question(text: str) -> str:
    value = text.lower()
    for pattern, replacement in SYNONYM_RULES:
        value = pattern.sub(replacement, value)
    value = re.sub(r"(吗|呢|呀|啊|嘛)+$", "", value)
    return NOISE_PATTERN.sub("", value)


def _cluster_questions(questions: list[CleanedQuestion]) -> list[QuestionCluster]:
    clusters: list[QuestionCluster] = []
    for question in questions:
        best_cluster: QuestionCluster | None = None
        best_score = 0.0
        for cluster in clusters:
            score = _similarity(question, cluster)
            if score > best_score:
                best_score = score
                best_cluster = cluster
        if best_cluster is not None and best_score >= 0.62:
            best_cluster.add(question)
        else:
            clusters.append(QuestionCluster(title=question.text, normalized=question.normalized, tokens=set(question.tokens), questions=[question]))
    return clusters


def _similarity(question: CleanedQuestion, cluster: QuestionCluster) -> float:
    if question.normalized == cluster.normalized:
        return 1.0
    token_score = _jaccard(question.tokens, cluster.tokens)
    sequence_score = SequenceMatcher(None, question.normalized, cluster.normalized).ratio()
    containment_score = _containment(question.normalized, cluster.normalized)
    return max(token_score, sequence_score * 0.88, containment_score)


def _tokens(text: str) -> set[str]:
    tokens = {item for item in re.split(r"[^\w\u4e00-\u9fff]+", text) if len(item) >= 2}
    compact = NOISE_PATTERN.sub("", text)
    for size in (2, 3):
        tokens.update(compact[index : index + size] for index in range(max(len(compact) - size + 1, 0)))
    return tokens


def _jaccard(left: set[str], right: set[str]) -> float:
    if not left or not right:
        return 0.0
    return len(left & right) / len(left | right)


def _containment(left: str, right: str) -> float:
    if not left or not right:
        return 0.0
    short, long = (left, right) if len(left) <= len(right) else (right, left)
    if len(short) < 4:
        return 0.0
    return 0.92 if short in long else 0.0


def _merge_tokens(left: set[str], right: set[str]) -> set[str]:
    if len(left) > 260:
        return set(Counter(left).keys())
    return left | right


def _looks_more_question_like(candidate: str, current: str) -> bool:
    markers = ("什么", "怎么", "如何", "为什么", "区别", "能不能", "可以")
    return any(marker in candidate for marker in markers) and not any(marker in current for marker in markers)


def _load_sample_context(
    db: Session,
    clusters: list[QuestionCluster],
) -> tuple[dict[int, str], dict[int, User]]:
    sample_questions: list[CleanedQuestion] = []
    for cluster in clusters:
        sample_questions.extend(
            sorted(cluster.questions, key=lambda item: item.created_at, reverse=True)[:5]
        )
    message_ids = {item.message_id for item in sample_questions}
    user_ids = {item.user_id for item in sample_questions}
    message_contents = {
        message_id: content
        for message_id, content in db.execute(
            select(ChatMessage.id, ChatMessage.content).where(ChatMessage.id.in_(message_ids))
        ).all()
    } if message_ids else {}
    users = {
        user.id: user
        for user in db.scalars(select(User).where(User.id.in_(user_ids))).all()
    } if user_ids else {}
    return message_contents, users


def _cluster_dict(
    rank: int,
    cluster: QuestionCluster,
    ai_logs: list[AiRequestLog],
    *,
    message_contents: dict[int, str],
    users: dict[int, User],
) -> dict:
    questions = sorted(cluster.questions, key=lambda item: item.created_at, reverse=True)
    user_ids = {item.user_id for item in questions}
    sessions = {item.session_id for item in questions}
    variants = Counter(item.text for item in questions).most_common(6)
    terms = _top_terms(questions)
    category, category_label = _classify_cluster(cluster)
    related_logs = _related_ai_logs(cluster, ai_logs)
    no_hit_count = sum(1 for item in related_logs if not item.knowledge_hit)
    failed_count = sum(1 for item in related_logs if (item.status or "").upper() != "SUCCESS")
    return {
        "rank": rank,
        "title": cluster.title,
        "normalized": cluster.normalized,
        "category": category,
        "categoryLabel": category_label,
        "count": len(questions),
        "userCount": len(user_ids),
        "sessionCount": len(sessions),
        "aiRequestCount": len(related_logs),
        "noHitCount": no_hit_count,
        "failedCount": failed_count,
        "needsKnowledgeFollowUp": _needs_knowledge_follow_up(category, no_hit_count, failed_count),
        "suggestedAction": _suggested_action(category, no_hit_count, failed_count),
        "firstSeenAt": min(item.created_at for item in questions),
        "lastSeenAt": max(item.created_at for item in questions),
        "topTerms": terms,
        "variants": [{"text": text, "count": count} for text, count in variants],
        "samples": [
            {
                "messageId": item.message_id,
                "sessionId": item.session_id,
                "userId": item.user_id,
                "userName": users[item.user_id].name if item.user_id in users else "",
                "userPhone": users[item.user_id].phone if item.user_id in users else "",
                "raw": message_contents.get(item.message_id, ""),
                "cleaned": item.text,
                "createdAt": item.created_at,
            }
            for item in questions[:5]
        ],
    }


def _top_terms(questions: list[CleanedQuestion]) -> list[str]:
    counter: Counter[str] = Counter()
    for question in questions:
        for token in question.tokens:
            if len(token) >= 2 and not token.isdigit():
                counter[token] += 1
    return [term for term, _count in counter.most_common(8)]


def _classify_cluster(cluster: QuestionCluster) -> tuple[str, str]:
    return _classify_text(f"{cluster.title}{cluster.normalized}")


def _classify_text(text: str) -> tuple[str, str]:
    rules = (
        ("fixed_info", "固定信息", ("上课安排", "回放", "会议链接", "课程助理", "时间", "链接", "权益", "联系方式", "安排")),
        ("homework", "功课操作", ("功课", "练习", "作业", "怎么做", "步骤", "操作")),
        ("course_knowledge", "课程知识", ("是什么", "区别", "意思", "概念", "课程", "知识")),
        ("emotion", "情绪梳理", ("情绪", "身体", "感受", "害怕", "愤怒", "委屈", "抗拒", "焦虑", "释放")),
        ("service", "服务权益", ("费用", "退款", "续费", "权益", "名额", "有效期", "购买")),
    )
    for key, label, keywords in rules:
        if any(keyword in text for keyword in keywords):
            return key, label
    return "other", "其他问题"


def _related_ai_logs(cluster: QuestionCluster, ai_logs: list[AiRequestLog]) -> list[AiRequestLog]:
    session_ids = {item.session_id for item in cluster.questions}
    matched: list[AiRequestLog] = []
    for log in ai_logs:
        if log.session_id not in session_ids:
            continue
        prompt = _normalize_question(_prompt_question_text(log.prompt or ""))
        if not prompt:
            continue
        score = max(SequenceMatcher(None, cluster.normalized, prompt).ratio(), _containment(cluster.normalized, prompt))
        if score >= 0.42 or cluster.normalized in prompt:
            matched.append(log)
    return matched


def _prompt_question_text(prompt: str) -> str:
    lines = [line.strip() for line in prompt.splitlines() if line.strip()]
    for line in lines[:12]:
        if line.startswith("用户问题") or line.startswith("问题") or line.startswith("用户提问"):
            return line
    return "\n".join(lines[:4])


def _needs_knowledge_follow_up(category: str, no_hit_count: int, failed_count: int) -> bool:
    if failed_count > 0:
        return True
    if no_hit_count <= 0:
        return False
    return category in {"fixed_info", "homework", "course_knowledge", "service"}


def _suggested_action(category: str, no_hit_count: int, failed_count: int) -> str:
    if failed_count > 0:
        return "优先查看 AI 请求失败原因，确认模型或工具链是否异常。"
    if no_hit_count > 0 and category == "fixed_info":
        return "建议优先补充或更新固定信息类知识库，并确认该库处于开放状态。"
    if no_hit_count > 0 and category in {"homework", "course_knowledge"}:
        return "建议检查课程知识库切片和召回结果，必要时补充课程章节或关键词。"
    if no_hit_count > 0:
        return "建议抽样查看原始对话，判断是否需要补知识库或优化无命中回答。"
    if category == "emotion":
        return "建议抽样检查回答是否回到当下、身体感受和觉察方向，避免建议过深过多。"
    if category == "service":
        return "建议确认服务权益和运营说明是否已有固定信息沉淀。"
    return "建议抽样查看相似问法，判断是否需要沉淀成知识库补充项。"
