"""
Load/save Studio projects and skill templates from data/agent/.
"""
from __future__ import annotations

import json
import logging
import re
import shutil
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional

from core.config import settings
from models.studio_models import (
    CreateStudioProjectRequest,
    PipelineDefinition,
    StudioProject,
    StudioProjectMeta,
    StudioProjectSummary,
    WorkflowTemplateSummary,
    WorkflowVariablesResponse,
    WorkflowVariableDef,
)

logger = logging.getLogger(__name__)

DEFAULT_TEMPLATE_ID = "builtin.studio.example"

POSITION_STRING_MAP = {
    "after_char": 0,
    "before_char": 1,
    "before_example": 2,
    "after_example": 3,
    "system": 4,
    "as_system": 5,
    "depth": 6,
    "macro": 7,
}

ACTIVATION_LEGACY_MAP = {
    "normal": "permanent",
    "constant": "permanent",
    "selective": "keyword",
}


def _normalize_position(value: Any) -> int:
    if value is None:
        return 1
    if isinstance(value, int):
        return value
    if isinstance(value, str):
        if value.isdigit():
            return int(value)
        return POSITION_STRING_MAP.get(value, 1)
    try:
        return int(value)
    except (TypeError, ValueError):
        return 1


def _normalize_activation(value: Any) -> str:
    if not value:
        return "permanent"
    text = str(value)
    return ACTIVATION_LEGACY_MAP.get(text, text)


def _migrate_scoring(scoring: Dict[str, Any]) -> Dict[str, Any]:
    if not scoring:
        return {"enabled": True, "dimensions": []}
    if scoring.get("dimensions"):
        return scoring
    rubric = scoring.get("rubric")
    if rubric:
        scoring = {**scoring}
        scoring["dimensions"] = [
            {
                "id": "default",
                "name": "综合质量",
                "criteria": rubric,
            }
        ]
        scoring.pop("rubric", None)
    return scoring


def _normalize_node(node: Dict[str, Any]) -> Dict[str, Any]:
    node = dict(node)
    config = dict(node.get("config") or {})
    insertion = dict(config.get("insertion") or {})
    if insertion:
        insertion["position"] = _normalize_position(insertion.get("position"))
        insertion["activationType"] = _normalize_activation(
            insertion.get("activationType")
        )
        config["insertion"] = insertion
    if "scoring" in config:
        config["scoring"] = _migrate_scoring(dict(config.get("scoring") or {}))
    node["config"] = config
    return node


def _parse_node_ref(ref: str, node_ids: set[str]) -> Optional[str]:
    if not ref or not ref.endswith(".output"):
        return None
    node_id = ref[: -len(".output")]
    return node_id if node_id in node_ids else None


def _build_node_dependency_edges(pipeline: Dict[str, Any]) -> List[tuple[str, str]]:
    nodes = pipeline.get("nodes") or []
    node_ids = {n["id"] for n in nodes if n.get("id")}
    edges: List[tuple[str, str]] = []
    seen: set[tuple[str, str]] = set()
    for node in nodes:
        to_id = node.get("id")
        if not to_id:
            continue
        for inp in node.get("inputs") or []:
            src = _parse_node_ref(inp.get("ref", ""), node_ids)
            if not src or src == to_id:
                continue
            pair = (src, to_id)
            if pair in seen:
                continue
            seen.add(pair)
            edges.append(pair)
    return edges


def _detect_reference_cycles(pipeline: Dict[str, Any]) -> List[List[str]]:
    nodes = pipeline.get("nodes") or []
    node_ids = [n["id"] for n in nodes if n.get("id")]
    adj: Dict[str, List[str]] = {nid: [] for nid in node_ids}
    for src, dst in _build_node_dependency_edges(pipeline):
        adj[src].append(dst)

    cycles: List[List[str]] = []
    visited: set[str] = set()
    stack: set[str] = set()
    path: List[str] = []

    def dfs(node_id: str) -> None:
        visited.add(node_id)
        stack.add(node_id)
        path.append(node_id)
        for nxt in adj.get(node_id, []):
            if nxt not in visited:
                dfs(nxt)
            elif nxt in stack:
                start = path.index(nxt)
                if start >= 0:
                    cycles.append(path[start:] + [nxt])
        path.pop()
        stack.discard(node_id)

    for nid in node_ids:
        if nid not in visited:
            dfs(nid)
    return cycles


def _validate_pipeline_refs(pipeline: Dict[str, Any]) -> None:
    cycles = _detect_reference_cycles(pipeline)
    if not cycles:
        return
    nodes = {n["id"]: n.get("displayName", n["id"]) for n in pipeline.get("nodes") or []}
    first = cycles[0]
    chain = " → ".join(nodes.get(nid, nid) for nid in first)
    raise ValueError(f"流水线存在循环引用：{chain}")


def _normalize_pipeline_dict(pipeline: Dict[str, Any]) -> Dict[str, Any]:
    pipeline = dict(pipeline)
    nodes = pipeline.get("nodes") or []
    pipeline["nodes"] = [_normalize_node(n) for n in nodes]
    return pipeline


def _read_json(path: Path) -> Any:
    with path.open("r", encoding="utf-8") as f:
        return json.load(f)


def _write_json(path: Path, data: Any) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)


def _slugify(name: str) -> str:
    slug = re.sub(r"[^\w\u4e00-\u9fff-]+", "-", name.strip(), flags=re.UNICODE)
    slug = re.sub(r"-+", "-", slug).strip("-").lower()
    return slug or "project"


class StudioProjectService:
    @property
    def projects_root(self) -> Path:
        return settings.AGENT_STUDIO_PROJECTS_PATH

    @property
    def templates_root(self) -> Path:
        return settings.AGENT_TEMPLATES_PATH

    def _project_dir(self, project_id: str) -> Path:
        return self.projects_root / project_id

    def _meta_path(self, project_id: str) -> Path:
        return self._project_dir(project_id) / "meta.json"

    def _pipeline_path(self, project_id: str) -> Path:
        return self._project_dir(project_id) / "pipeline.json"

    def list_projects(self) -> List[StudioProjectSummary]:
        root = self.projects_root
        if not root.exists():
            return []
        summaries: List[StudioProjectSummary] = []
        for child in sorted(root.iterdir()):
            if not child.is_dir():
                continue
            meta_path = child / "meta.json"
            if not meta_path.exists():
                continue
            meta = _read_json(meta_path)
            summaries.append(
                StudioProjectSummary(
                    id=meta.get("id", child.name),
                    name=meta.get("name", child.name),
                    description=meta.get("description", ""),
                    updatedAt=meta.get("updatedAt", ""),
                )
            )
        return summaries

    def get_project(self, project_id: str) -> StudioProject:
        meta_path = self._meta_path(project_id)
        pipeline_path = self._pipeline_path(project_id)
        if not meta_path.exists() or not pipeline_path.exists():
            raise FileNotFoundError(f"Studio project not found: {project_id}")
        meta = _read_json(meta_path)
        pipeline = _normalize_pipeline_dict(_read_json(pipeline_path))
        return StudioProject(
            meta=StudioProjectMeta(**meta),
            pipeline=PipelineDefinition(**pipeline),
        )

    def update_project_bindings(
        self,
        project_id: str,
        character_id: str,
        worldbook_id: str,
    ) -> StudioProject:
        meta_path = self._meta_path(project_id)
        if not meta_path.exists():
            raise FileNotFoundError(f"Studio project not found: {project_id}")
        meta = _read_json(meta_path)
        meta["characterId"] = character_id
        meta["worldbookId"] = worldbook_id
        meta["updatedAt"] = datetime.now().isoformat()
        _write_json(meta_path, meta)
        return self.get_project(project_id)

    def update_project_meta(
        self,
        project_id: str,
        *,
        name: Optional[str] = None,
        description: Optional[str] = None,
    ) -> StudioProject:
        meta_path = self._meta_path(project_id)
        if not meta_path.exists():
            raise FileNotFoundError(f"Studio project not found: {project_id}")
        meta = _read_json(meta_path)
        if name is not None:
            meta["name"] = name.strip()
        if description is not None:
            meta["description"] = description
        meta["updatedAt"] = datetime.now().isoformat()
        _write_json(meta_path, meta)
        return self.get_project(project_id)

    def save_pipeline(self, project_id: str, pipeline: PipelineDefinition) -> StudioProject:
        meta_path = self._meta_path(project_id)
        if not meta_path.exists():
            raise FileNotFoundError(f"Studio project not found: {project_id}")
        normalized = _normalize_pipeline_dict(pipeline.model_dump(exclude_none=True))
        _validate_pipeline_refs(normalized)
        meta = _read_json(meta_path)
        now = datetime.now().isoformat()
        meta["updatedAt"] = now
        _write_json(meta_path, meta)
        _write_json(self._pipeline_path(project_id), normalized)
        return self.get_project(project_id)

    def list_workflow_templates(self) -> List[WorkflowTemplateSummary]:
        root = self.templates_root
        if not root.exists():
            return []
        summaries: List[WorkflowTemplateSummary] = []
        for child in sorted(root.iterdir()):
            if not child.is_dir():
                continue
            meta_path = child / "meta.json"
            if not meta_path.exists():
                continue
            meta = _read_json(meta_path)
            summaries.append(
                WorkflowTemplateSummary(
                    id=meta.get("id", child.name),
                    name=meta.get("name", child.name),
                    description=meta.get("description", ""),
                )
            )
        return summaries

    def get_workflow_variables(self, project_id: Optional[str] = None) -> WorkflowVariablesResponse:
        path = settings.AGENT_WORKFLOW_VARIABLES_FILE
        if path.exists():
            raw = _read_json(path)
        else:
            raw = {
                "builtIn": [
                    {"ref": "workflow.goal", "label": "工作流目标文本", "description": ""},
                    {"ref": "workflow.boundWorldbook", "label": "绑定世界书摘要", "description": ""},
                    {"ref": "workflow.boundCharacter", "label": "绑定角色卡摘要", "description": ""},
                ],
                "dynamicSuffixes": [
                    {"suffix": ".output", "labelPattern": "{displayName} · 上轮产物"},
                    {"suffix": ".entryDraft", "labelPattern": "{displayName} · 条目草稿"},
                ],
            }
        built_in = [
            WorkflowVariableDef(**item) for item in raw.get("builtIn", [])
        ]
        dynamic: List[WorkflowVariableDef] = []
        suffixes = raw.get("dynamicSuffixes") or [
            {"suffix": ".output", "labelPattern": "{displayName} · 世界书条目"},
        ]
        if project_id:
            try:
                project = self.get_project(project_id)
                for node in project.pipeline.nodes:
                    if not node.enabled:
                        continue
                    if node.skillId != "studio.worldbook_entry":
                        continue
                    for suffix_def in suffixes:
                        suffix = suffix_def.get("suffix", ".output")
                        if suffix != ".output":
                            continue
                        pattern = suffix_def.get(
                            "labelPattern", "{displayName} · 世界书条目"
                        )
                        ref = f"{node.id}{suffix}"
                        label = pattern.replace("{displayName}", node.displayName)
                        dynamic.append(
                            WorkflowVariableDef(ref=ref, label=label, description="")
                        )
            except FileNotFoundError:
                pass
        return WorkflowVariablesResponse(builtIn=built_in, dynamic=dynamic)

    def get_skill_templates(self) -> Dict[str, Any]:
        path = settings.AGENT_SKILL_TEMPLATES_FILE
        if not path.exists():
            raise FileNotFoundError("skill_templates.json not found")
        return _read_json(path)

    def get_niches(self) -> Dict[str, Any]:
        path = settings.AGENT_NICHES_FILE
        if not path.exists():
            return {"niches": []}
        return _read_json(path)

    def _unique_project_id(self, base_id: str) -> str:
        candidate = base_id
        n = 1
        while self._project_dir(candidate).exists():
            candidate = f"{base_id}-{n}"
            n += 1
        return candidate

    def create_project(self, req: CreateStudioProjectRequest) -> StudioProject:
        template_id = req.template_id or DEFAULT_TEMPLATE_ID
        template_dir = self.templates_root / template_id
        if not template_dir.exists():
            raise FileNotFoundError(f"Studio template not found: {template_id}")

        base_id = req.project_id or _slugify(req.name)
        project_id = self._unique_project_id(base_id)
        dest = self._project_dir(project_id)
        dest.mkdir(parents=True, exist_ok=False)

        template_meta = _read_json(template_dir / "meta.json")
        template_pipeline = _read_json(template_dir / "pipeline.json")
        now = datetime.now().isoformat()

        meta = {
            "id": project_id,
            "name": req.name,
            "description": template_meta.get("description", ""),
            "templateId": template_id,
            "characterId": None,
            "worldbookId": None,
            "createdAt": now,
            "updatedAt": now,
        }
        _write_json(dest / "meta.json", meta)
        _write_json(dest / "pipeline.json", _normalize_pipeline_dict(template_pipeline))
        return self.get_project(project_id)

    def delete_project(self, project_id: str) -> None:
        project_dir = self._project_dir(project_id)
        if not project_dir.exists():
            raise FileNotFoundError(f"Studio project not found: {project_id}")
        shutil.rmtree(project_dir)
        runs_dir = settings.AGENT_STUDIO_RUNS_PATH / project_id
        if runs_dir.exists():
            shutil.rmtree(runs_dir)

    def ensure_default_project(self) -> None:
        """Copy example template into default project if missing."""
        default_dir = self._project_dir("default")
        if default_dir.exists():
            return
        template_dir = self.templates_root / DEFAULT_TEMPLATE_ID
        if not template_dir.exists():
            logger.warning("builtin.studio.example template missing; skip default project seed")
            return
        default_dir.mkdir(parents=True, exist_ok=True)
        template_meta = _read_json(template_dir / "meta.json")
        now = datetime.now().isoformat()
        meta = {
            "id": "default",
            "name": "示例角色项目",
            "description": template_meta.get("description", ""),
            "templateId": DEFAULT_TEMPLATE_ID,
            "characterId": None,
            "worldbookId": None,
            "createdAt": now,
            "updatedAt": now,
        }
        _write_json(default_dir / "meta.json", meta)
        shutil.copy2(template_dir / "pipeline.json", default_dir / "pipeline.json")


studio_project_service = StudioProjectService()

try:
    studio_project_service.ensure_default_project()
except Exception as _seed_err:
    logger.warning("Studio default project seed skipped: %s", _seed_err)
