from __future__ import annotations

import hashlib
import json
import re
from dataclasses import dataclass
from datetime import UTC, datetime

from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session

from app.models.knowledge import (
    Knowledge,
    KnowledgeCard,
    KnowledgeChunk,
    KnowledgeManifest,
    KnowledgePublishLog,
    KnowledgeReviewRecord,
    KnowledgeSection,
    KnowledgeSourceSnapshot,
    KnowledgeSyncJob,
    KnowledgeVersion,
)
from app.services.external_errors import ExternalServiceError
from app.services.feishu_async_service import AsyncFeishuKnowledgeService
from app.services.feishu_service import _feishu_retrieval_config

PROCESSING_RULE_VERSION = "knowledge-pipeline-v4-semantic-practice"
MAX_SECTION_CHARS = 8000
MAX_CHUNK_CHARS = 650

_PRACTICE_ANCHOR = re.compile(
    r"(?:^|[、\s])(?:练习|作业|功课)\s*[一二三四五六七八九十百零〇\d]+(?:[：:、.．\s]|$)"
)
_NUMBERED_HEADING = re.compile(r"^(?:第)?[一二三四五六七八九十百零〇\d]+[、.．:：]\s*")


@dataclass(frozen=True)
class ParsedSection:
    title: str
    content: str
    source_start: int
    source_end: int


class KnowledgePipelineService:
    @classmethod
    async def synchronize(
        cls,
        db: Session,
        knowledge: Knowledge,
        *,
        admin_id: int,
    ) -> KnowledgeSyncJob:
        job = KnowledgeSyncJob(
            knowledge_id=knowledge.id,
            mode="replace",
            status="running",
            stage="fetching",
            progress=5,
            requested_by=admin_id,
            started_at=_now(),
        )
        db.add(job)
        db.commit()
        db.refresh(job)
        try:
            config = _feishu_retrieval_config(db)
            scope = _scope_from_knowledge(knowledge)
            documents = await AsyncFeishuKnowledgeService._load_documents(scope, config)
            if not documents:
                raise ExternalServiceError("飞书文章内容为空或无法读取", provider="feishu")
            source_title, source_content, _ = documents[0]
            if len(documents) > 1:
                source_content = "\n\n".join(f"# {title}\n{content}" for title, content, _ in documents)
            source_content = source_content.strip()
            if not source_content:
                raise ExternalServiceError("飞书文章内容为空", provider="feishu")

            cls._set_job(db, job, "snapshot", 20)
            digest = _hash(source_content)
            latest_snapshot = db.scalar(
                select(KnowledgeSourceSnapshot)
                .where(KnowledgeSourceSnapshot.knowledge_id == knowledge.id)
                .order_by(KnowledgeSourceSnapshot.id.desc())
                .limit(1)
            )
            if latest_snapshot and latest_snapshot.content_hash == digest:
                knowledge.source_status = "normal"
                knowledge.source_error = None
                knowledge.source_title = source_title
                knowledge.last_synced_at = _now()
                latest_version = db.scalar(
                    select(KnowledgeVersion)
                    .where(KnowledgeVersion.snapshot_id == latest_snapshot.id)
                    .order_by(KnowledgeVersion.id.desc())
                    .limit(1)
                )
                if latest_version is not None and latest_version.id != knowledge.current_version_id:
                    if latest_version.quality_status == "blocked":
                        raise ValueError("最新同步版本质检未通过，已保留当前正式版本")
                    cls._activate_version(
                        db,
                        knowledge,
                        latest_version,
                        admin_id,
                        keep_open=knowledge.status == 1,
                        action="sync_publish",
                    )
                    job.status = "published"
                    job.result_summary = json.dumps(
                        {
                            "changed": False,
                            "activatedExistingVersion": True,
                            "versionId": latest_version.id,
                            "versionNo": latest_version.version_no,
                        },
                        ensure_ascii=False,
                    )
                else:
                    job.status = "unchanged"
                    job.result_summary = json.dumps({"changed": False}, ensure_ascii=False)
                    if latest_version is not None:
                        cls._prune_old_versions(db, knowledge, latest_version)
                job.stage = "completed"
                job.progress = 100
                job.finished_at = _now()
                db.add_all([knowledge, job])
                db.commit()
                return job

            snapshot = KnowledgeSourceSnapshot(
                knowledge_id=knowledge.id,
                sync_job_id=job.id,
                source_title=source_title,
                source_content=source_content,
                content_hash=digest,
                source_identifier=f"{knowledge.feishu_space_id}/{knowledge.feishu_node_id}",
                fetched_at=_now(),
                created_by=admin_id,
            )
            db.add(snapshot)
            db.flush()

            version_no = int(
                db.scalar(select(func.coalesce(func.max(KnowledgeVersion.version_no), 0)).where(
                    KnowledgeVersion.knowledge_id == knowledge.id
                ))
                or 0
            ) + 1
            version = KnowledgeVersion(
                knowledge_id=knowledge.id,
                snapshot_id=snapshot.id,
                version_no=version_no,
                status="processing",
                quality_status="pending",
                processing_rule_version=PROCESSING_RULE_VERSION,
                created_by=admin_id,
            )
            db.add(version)
            db.flush()

            cls._set_job(db, job, "parsing", 40)
            sections = parse_sections(source_content)
            section_rows: list[KnowledgeSection] = []
            chunk_count = 0
            for index, parsed in enumerate(sections, start=1):
                section = KnowledgeSection(
                    knowledge_id=knowledge.id,
                    version_id=version.id,
                    section_key=f"S{index:04d}",
                    title=parsed.title,
                    content=parsed.content,
                    source_start=parsed.source_start,
                    source_end=parsed.source_end,
                    sort_order=index,
                    content_hash=_hash(parsed.content),
                )
                db.add(section)
                db.flush()
                section_rows.append(section)
                for chunk_index, (chunk_content, start, end) in enumerate(split_chunks(parsed), start=1):
                    terms = extract_terms(f"{parsed.title} {chunk_content}")
                    db.add(
                        KnowledgeChunk(
                            knowledge_id=knowledge.id,
                            version_id=version.id,
                            section_id=section.id,
                            title=parsed.title,
                            content=chunk_content,
                            normalized_text=" ".join(terms),
                            keywords=json.dumps(terms[:30], ensure_ascii=False),
                            synonyms=json.dumps(expand_synonyms(terms[:20]), ensure_ascii=False),
                            source_start=start,
                            source_end=end,
                            sort_order=chunk_index,
                            content_hash=_hash(chunk_content),
                        )
                    )
                    chunk_count += 1

            cls._set_job(db, job, "cards", 65)
            for section in section_rows:
                terms = extract_terms(f"{section.title} {section.content}")
                db.add(
                    KnowledgeCard(
                        knowledge_id=knowledge.id,
                        version_id=version.id,
                        section_id=section.id,
                        title=section.title,
                        summary=_summary(section.content, 180),
                        core_conclusion=_summary(section.content, 260),
                        applicable_questions="、".join(terms[:8]) or section.title,
                        inapplicable_questions="与本章节主题无直接关系的问题",
                        keywords=json.dumps(terms[:30], ensure_ascii=False),
                        synonyms=json.dumps(expand_synonyms(terms[:20]), ensure_ascii=False),
                        entities=json.dumps(terms[:12], ensure_ascii=False),
                        risk_level=detect_risk_level(section.content),
                        source_range=f"{section.source_start}-{section.source_end}",
                        content_hash=section.content_hash,
                        generation_model="deterministic-v1",
                        generation_rule_version=PROCESSING_RULE_VERSION,
                        review_status="approved",
                    )
                )

            manifest_data = build_manifest(source_title, source_content, section_rows)
            previous_manifest = None
            if knowledge.current_version_id:
                previous_manifest = db.scalar(
                    select(KnowledgeManifest).where(KnowledgeManifest.version_id == knowledge.current_version_id)
                )
            change_level = manifest_change_level(previous_manifest, manifest_data)
            manifest = KnowledgeManifest(
                knowledge_id=knowledge.id,
                version_id=version.id,
                purpose=manifest_data["purpose"],
                applicable_questions=manifest_data["applicableQuestions"],
                inapplicable_questions=manifest_data["inapplicableQuestions"],
                core_topics=manifest_data["coreTopics"],
                entities=manifest_data["entities"],
                boundaries=manifest_data["boundaries"],
                content_hash=_hash(json.dumps(manifest_data, ensure_ascii=False, sort_keys=True)),
                change_level=change_level,
                confirmed=1,
                confirmed_by=admin_id,
                confirmed_at=_now(),
            )
            db.add(manifest)

            cls._set_job(db, job, "quality", 82)
            quality = quality_check(source_content, sections, chunk_count)
            version.quality_status = "passed" if not quality["blocking"] else "blocked"
            version.quality_report = json.dumps(quality, ensure_ascii=False)
            version.change_summary = json.dumps(
                {"sections": len(sections), "chunks": chunk_count, "manifestChange": change_level},
                ensure_ascii=False,
            )
            if quality["blocking"]:
                issue_text = "；".join(
                    item["message"] for item in quality["issues"] if item["level"] == "blocking"
                )
                raise ValueError(f"同步内容质检未通过：{issue_text or '存在阻断项'}")

            knowledge.source_title = source_title
            knowledge.source_status = "normal"
            knowledge.source_error = None
            knowledge.last_synced_at = _now()
            cls._activate_version(
                db,
                knowledge,
                version,
                admin_id,
                keep_open=knowledge.status == 1,
                action="sync_publish",
            )
            job.status = "published"
            job.stage = "completed"
            job.progress = 100
            job.result_summary = json.dumps(
                {"versionId": version.id, "versionNo": version.version_no, "autoPublished": True, **quality},
                ensure_ascii=False,
            )
            job.finished_at = _now()
            db.add_all([knowledge, version, job])
            db.commit()
            return job
        except Exception as exc:
            db.rollback()
            stored_job = db.get(KnowledgeSyncJob, job.id)
            stored_knowledge = db.get(Knowledge, knowledge.id)
            if stored_job:
                stored_job.status = "failed"
                stored_job.stage = "failed"
                stored_job.source_error = str(exc)
                stored_job.finished_at = _now()
                db.add(stored_job)
            if stored_knowledge:
                stored_knowledge.source_status = "error"
                stored_knowledge.source_error = str(exc)
                stored_knowledge.migration_status = "failed"
                db.add(stored_knowledge)
            db.commit()
            raise

    @staticmethod
    def _activate_version(
        db: Session,
        knowledge: Knowledge,
        version: KnowledgeVersion,
        admin_id: int,
        *,
        keep_open: bool,
        action: str,
    ) -> None:
        """Atomically replace the active version with validated latest content."""
        if version.quality_status == "blocked":
            raise ValueError("质检未通过的版本不能生效")
        if keep_open and knowledge.lifecycle_status != "active":
            raise ValueError("归档知识库不能在版本替换后开放")
        if keep_open and knowledge.source_status != "normal":
            raise ValueError("飞书源文章异常，不能替换正式版本")
        previous_version_id = knowledge.current_version_id
        version.status = "published"
        version.published_by = admin_id
        version.published_at = _now()
        knowledge.current_version_id = version.id
        knowledge.pending_version_id = None
        knowledge.manifest_confirmed = 1
        knowledge.last_published_at = version.published_at
        knowledge.status = 1 if keep_open else 0
        knowledge.migration_status = "completed"
        db.add_all(
            [
                version,
                knowledge,
                KnowledgePublishLog(
                    knowledge_id=knowledge.id,
                    from_version_id=previous_version_id,
                    to_version_id=version.id,
                    action=action,
                    keep_open=knowledge.status,
                    operated_by=admin_id,
                ),
            ]
        )
        db.flush()
        KnowledgePipelineService._prune_old_versions(db, knowledge, version)

    @staticmethod
    def _prune_old_versions(db: Session, knowledge: Knowledge, keep: KnowledgeVersion) -> None:
        """Keep only the active version and its derived retrieval data."""
        old_version_ids = list(db.scalars(
            select(KnowledgeVersion.id).where(
                KnowledgeVersion.knowledge_id == knowledge.id,
                KnowledgeVersion.id != keep.id,
            )
        ).all())
        if old_version_ids:
            for model in (
                KnowledgeReviewRecord,
                KnowledgeCard,
                KnowledgeChunk,
                KnowledgeSection,
                KnowledgeManifest,
            ):
                db.execute(delete(model).where(model.version_id.in_(old_version_ids)))
            db.execute(delete(KnowledgeVersion).where(KnowledgeVersion.id.in_(old_version_ids)))

        # Snapshots are source copies for a version. Once that version is gone,
        # retaining the old snapshot would still be retaining knowledge history.
        db.execute(
            delete(KnowledgeSourceSnapshot).where(
                KnowledgeSourceSnapshot.knowledge_id == knowledge.id,
                KnowledgeSourceSnapshot.id != keep.snapshot_id,
            )
        )

    @staticmethod
    def _set_job(db: Session, job: KnowledgeSyncJob, stage: str, progress: int) -> None:
        job.stage = stage
        job.progress = progress
        db.add(job)
        db.flush()


def parse_sections(content: str) -> list[ParsedSection]:
    heading = re.compile(r"(?m)^(#{1,6}\s+.+|[^\n]{1,80}\n[-=]{3,})$")
    matches = list(heading.finditer(content))
    boundaries: list[tuple[int, str]] = []
    for match in matches:
        raw = match.group(0).splitlines()[0].lstrip("#").strip()
        boundaries.append((match.start(), raw))
    if not boundaries or boundaries[0][0] > 0:
        boundaries.insert(0, (0, "正文"))
    spans: list[tuple[int, int, str]] = []
    index = 0
    while index < len(boundaries):
        start, title = boundaries[index]
        next_index = index + 1
        if is_practice_anchor_title(title):
            while next_index < len(boundaries):
                next_title = boundaries[next_index][1]
                if is_practice_anchor_title(next_title):
                    break
                if is_numbered_heading_title(next_title) and not practice_titles_related(title, next_title):
                    break
                next_index += 1
        end = boundaries[next_index][0] if next_index < len(boundaries) else len(content)
        spans.append((start, end, title))
        index = next_index

    sections: list[ParsedSection] = []
    for start, end, title in spans:
        block = content[start:end].strip()
        if not block:
            continue
        cursor = 0
        while len(block) - cursor > MAX_SECTION_CHARS:
            cut = block.rfind("\n\n", cursor, cursor + MAX_SECTION_CHARS)
            if cut <= cursor:
                cut = cursor + MAX_SECTION_CHARS
            part = block[cursor:cut].strip()
            sections.append(ParsedSection(title, part, start + cursor, start + cut))
            cursor = cut
        tail = block[cursor:].strip()
        if tail:
            sections.append(ParsedSection(title, tail, start + cursor, end))
    return sections or [ParsedSection("正文", content, 0, len(content))]


def is_practice_anchor_title(title: str) -> bool:
    return bool(_PRACTICE_ANCHOR.search(title.strip()))


def is_numbered_heading_title(title: str) -> bool:
    return bool(_NUMBERED_HEADING.match(title.strip()))


def practice_titles_related(anchor: str, candidate: str) -> bool:
    """Recognize numbered safety/boundary headings that still belong to a practice."""
    anchor_topic = _PRACTICE_ANCHOR.sub("", _NUMBERED_HEADING.sub("", anchor)).strip(" ：:、")
    candidate_text = _NUMBERED_HEADING.sub("", candidate)
    normalized = re.sub(r"[\s·・，。、“”《》：:（）()\-]", "", anchor_topic)
    candidate_normalized = re.sub(r"[\s·・，。、“”《》：:（）()\-]", "", candidate_text)
    ignored = {"练习", "作业", "功课", "静心", "方法", "步骤", "课程"}
    pairs = {
        normalized[index : index + 2]
        for index in range(max(0, len(normalized) - 1))
        if normalized[index : index + 2] not in ignored
    }
    return any(pair in candidate_normalized for pair in pairs)


def split_chunks(section: ParsedSection) -> list[tuple[str, int, int]]:
    chunks: list[tuple[str, int, int]] = []
    cursor = 0
    while cursor < len(section.content):
        end = min(len(section.content), cursor + MAX_CHUNK_CHARS)
        if end < len(section.content):
            boundary = max(section.content.rfind("\n\n", cursor, end), section.content.rfind("。", cursor, end))
            if boundary > cursor + 120:
                end = boundary + 1
        text = section.content[cursor:end].strip()
        if text:
            chunks.append((text, section.source_start + cursor, section.source_start + end))
        cursor = end
    return chunks


def extract_terms(text: str) -> list[str]:
    normalized = re.sub(r"\s+", "", text.lower())
    ascii_words = re.findall(r"[a-z0-9_]{2,}", normalized)
    chinese_runs = re.findall(r"[\u4e00-\u9fff]+", normalized)
    terms = list(ascii_words)
    for run in chinese_runs:
        if len(run) <= 4:
            terms.append(run)
        for size in (2, 3, 4):
            terms.extend(run[i : i + size] for i in range(max(0, len(run) - size + 1)))
    return list(dict.fromkeys(term for term in terms if term))


_SYNONYMS = {
    "孩子": ["小孩", "学生", "子女"],
    "家长": ["父母", "妈妈", "爸爸"],
    "学习": ["功课", "学业", "上课"],
    "焦虑": ["担心", "紧张", "害怕"],
    "沟通": ["交流", "对话", "聊天"],
    "课程": ["训练营", "大本营", "课堂"],
    "作业": ["功课", "练习"],
    "功课": ["作业", "练习"],
    "练习": ["作业", "功课"],
}


def expand_synonyms(terms: list[str]) -> list[str]:
    values: list[str] = []
    for term in terms:
        values.extend(_SYNONYMS.get(term, []))
    return list(dict.fromkeys(values))


def build_manifest(title: str, content: str, sections: list[KnowledgeSection]) -> dict[str, str]:
    fields = {}
    for label, key in [
        ("知识库用途", "purpose"),
        ("适用问题", "applicableQuestions"),
        ("不适用问题", "inapplicableQuestions"),
        ("核心主题", "coreTopics"),
    ]:
        match = re.search(rf"{label}[：:]\s*([^\n]+)", content)
        if match:
            fields[key] = match.group(1).strip()
    topics = "、".join(list(dict.fromkeys(section.title for section in sections))[:12])
    return {
        "purpose": fields.get("purpose", f"提供与《{title}》相关的课程、答疑和业务知识"),
        "applicableQuestions": fields.get("applicableQuestions", topics or title),
        "inapplicableQuestions": fields.get("inapplicableQuestions", "与本文主题无直接关系的一般问题"),
        "coreTopics": fields.get("coreTopics", topics or title),
        "entities": topics,
        "boundaries": "只能围绕具体问题提供必要解释，不输出整篇原文或大段连续课程资料。",
    }


def manifest_change_level(previous: KnowledgeManifest | None, current: dict[str, str]) -> str:
    if previous is None:
        return "major"
    major_fields = ["purpose", "applicableQuestions", "inapplicableQuestions", "coreTopics"]
    old = [previous.purpose, previous.applicable_questions, previous.inapplicable_questions, previous.core_topics]
    new = [current[field] for field in major_fields]
    return "minor" if all(_similar(left, right) >= 0.72 for left, right in zip(old, new, strict=True)) else "major"


def quality_check(content: str, sections: list[ParsedSection], chunk_count: int) -> dict:
    duplicate_hashes = [_hash(item.content) for item in sections]
    issues: list[dict[str, str]] = []
    if len(content) < 50:
        issues.append({"level": "blocking", "code": "content_too_short", "message": "源文章内容过短"})
    if not sections or chunk_count == 0:
        issues.append({"level": "blocking", "code": "parse_empty", "message": "未生成章节或切片"})
    if len(set(duplicate_hashes)) != len(duplicate_hashes):
        issues.append({"level": "warning", "code": "duplicate_sections", "message": "存在重复章节"})
    high_risk = detect_risk_level(content) == "high"
    if high_risk:
        issues.append({"level": "warning", "code": "high_risk", "message": "包含高风险主题，请重点关注内容准确性"})
    return {
        "blocking": any(item["level"] == "blocking" for item in issues),
        "issues": issues,
        "sectionCount": len(sections),
        "chunkCount": chunk_count,
        "highRisk": high_risk,
    }


def detect_risk_level(text: str) -> str:
    high = ("自杀", "自伤", "伤人", "违法", "医疗", "诊断", "药物", "投资", "法律")
    return "high" if any(word in text for word in high) else "normal"


def _summary(text: str, limit: int) -> str:
    value = re.sub(r"\s+", " ", text).strip()
    return value if len(value) <= limit else value[:limit].rstrip() + "…"


def _hash(value: str) -> str:
    return hashlib.sha256(value.encode("utf-8")).hexdigest()


def _similar(left: str, right: str) -> float:
    left_terms, right_terms = set(extract_terms(left)), set(extract_terms(right))
    return len(left_terms & right_terms) / max(1, len(left_terms | right_terms))


def _now() -> datetime:
    return datetime.now(UTC).replace(tzinfo=None)


def _scope_from_knowledge(knowledge: Knowledge):
    from app.services.knowledge_service import KnowledgeScope

    return KnowledgeScope(
        id=knowledge.id,
        name=knowledge.name,
        feishu_space_id=knowledge.feishu_space_id,
        feishu_node_id=knowledge.feishu_node_id,
    )
