"""
Novelty-Driven Task Agent - An autonomous agent that generates tasks using novelty as termination condition.

This agent operates in a while loop, generating tasks from diverse expert perspectives,
and terminates when it finds a task that exceeds the novelty threshold (a "breakthrough").

The core innovation is using novelty assessment to help the agent "jump out" of its
trained data distribution (semantic gravity), finding truly novel ideas.

Architecture:
    Seed Problem → Expert Sample → Task Generation → Novelty Assessment → Continue/Stop

Termination Strategy: "Seek Breakthrough"
    - Continue until novelty > threshold
    - Find the first truly novel task and stop

Research Foundation:
    - Novelty Search (Lehman & Stanley): Reward novelty, not objectives
    - Curiosity-driven Exploration (Pathak et al.): Intrinsic motivation via prediction error
    - Quality-Diversity (MAP-Elites): Maintain diverse high-quality solutions
"""

import asyncio
import json
import logging
import random
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, List, Optional

import httpx
import numpy as np

from .novelty_metrics import NoveltyMetrics, NoveltyScore, NoveltyTrajectory

logger = logging.getLogger(__name__)


# ============================================================================
# Data Classes
# ============================================================================

@dataclass
class GeneratedTask:
    """A single generated task with metadata."""
    task: str
    expert: str
    expert_domain: str
    novelty_score: float
    iteration: int
    is_breakthrough: bool = False
    embedding: Optional[np.ndarray] = None


@dataclass
class TaskGenerationResult:
    """Result of a complete novelty-driven task generation session."""
    seed_problem: str
    breakthrough_task: Optional[GeneratedTask] = None
    trajectory: List[GeneratedTask] = field(default_factory=list)
    total_iterations: int = 0
    terminated_by: str = "unknown"  # "breakthrough", "max_iterations", "error"
    novelty_trajectory: Optional[NoveltyTrajectory] = None
    start_time: Optional[str] = None
    end_time: Optional[str] = None
    config: dict = field(default_factory=dict)

    def to_dict(self) -> dict:
        """Convert to dictionary for JSON serialization."""
        return {
            "seed_problem": self.seed_problem,
            "breakthrough_task": {
                "task": self.breakthrough_task.task,
                "expert": self.breakthrough_task.expert,
                "expert_domain": self.breakthrough_task.expert_domain,
                "novelty_score": self.breakthrough_task.novelty_score,
                "iteration": self.breakthrough_task.iteration
            } if self.breakthrough_task else None,
            "trajectory": [
                {
                    "task": t.task,
                    "expert": t.expert,
                    "expert_domain": t.expert_domain,
                    "novelty_score": t.novelty_score,
                    "iteration": t.iteration,
                    "is_breakthrough": t.is_breakthrough
                }
                for t in self.trajectory
            ],
            "total_iterations": self.total_iterations,
            "terminated_by": self.terminated_by,
            "novelty_stats": {
                "mean_novelty": self.novelty_trajectory.mean_novelty if self.novelty_trajectory else 0,
                "max_novelty": self.novelty_trajectory.max_novelty if self.novelty_trajectory else 0,
                "jump_ratio": self.novelty_trajectory.jump_ratio if self.novelty_trajectory else 0,
                "cumulative_novelty": self.novelty_trajectory.final_cumulative_novelty if self.novelty_trajectory else 0
            },
            "start_time": self.start_time,
            "end_time": self.end_time,
            "config": self.config
        }


# ============================================================================
# Expert/Domain Providers
# ============================================================================

class ExpertProvider:
    """Provides random experts from curated occupation lists."""

    def __init__(self, data_dir: Optional[Path] = None, language: str = "en"):
        """
        Args:
            data_dir: Path to data directory containing occupation JSON files
            language: Language code ("en" or "zh")
        """
        if data_dir is None:
            # Default to backend data directory
            data_dir = Path(__file__).parent.parent.parent / "backend" / "app" / "data"

        self.data_dir = data_dir
        self.language = language
        self._occupations: List[dict] = []
        self._load_occupations()

    def _load_occupations(self):
        """Load occupations from JSON file."""
        file_path = self.data_dir / f"curated_occupations_{self.language}.json"

        if not file_path.exists():
            logger.warning(f"Occupation file not found: {file_path}")
            # Fallback to some default experts
            self._occupations = [
                {"name": "Marine Biologist", "domain": "Science"},
                {"name": "Choreographer", "domain": "Arts"},
                {"name": "Urban Planner", "domain": "Architecture"},
                {"name": "Chef", "domain": "Culinary"},
                {"name": "Astronomer", "domain": "Science"},
            ]
            return

        try:
            with open(file_path, "r", encoding="utf-8") as f:
                data = json.load(f)
            self._occupations = data.get("occupations", [])
            logger.info(f"Loaded {len(self._occupations)} occupations from {file_path.name}")
        except Exception as e:
            logger.error(f"Error loading occupations: {e}")
            self._occupations = []

    def get_random_expert(self) -> dict:
        """Get a random expert with name and domain."""
        if not self._occupations:
            return {"name": "Expert", "domain": "General"}
        return random.choice(self._occupations)

    def get_random_experts(self, count: int) -> List[dict]:
        """Get multiple random experts without replacement."""
        if len(self._occupations) <= count:
            return self._occupations.copy()
        return random.sample(self._occupations, count)


class DomainProvider:
    """Provides random knowledge domains from DDC classification."""

    def __init__(self, data_dir: Optional[Path] = None, language: str = "en"):
        if data_dir is None:
            data_dir = Path(__file__).parent.parent.parent / "backend" / "app" / "data"

        self.data_dir = data_dir
        self.language = language
        self._domains: List[dict] = []
        self._load_domains()

    def _load_domains(self):
        """Load domains from JSON file."""
        file_path = self.data_dir / f"ddc_domains_{self.language}.json"

        if not file_path.exists():
            logger.warning(f"Domain file not found: {file_path}")
            self._domains = []
            return

        try:
            with open(file_path, "r", encoding="utf-8") as f:
                data = json.load(f)
            self._domains = data.get("domains", [])
            logger.info(f"Loaded {len(self._domains)} domains from {file_path.name}")
        except Exception as e:
            logger.error(f"Error loading domains: {e}")

    def get_random_domain(self, level: Optional[str] = None) -> dict:
        """Get a random domain, optionally filtered by level."""
        domains = self._domains
        if level:
            domains = [d for d in domains if d.get("level") == level]

        if not domains:
            return {"name": "General Knowledge", "code": "000"}
        return random.choice(domains)


# ============================================================================
# Novelty-Driven Task Agent
# ============================================================================

class NoveltyDrivenTaskAgent:
    """
    An autonomous agent that generates tasks using novelty as the termination condition.

    The agent operates in a loop:
    1. Sample a random expert perspective
    2. Generate a task from that expert's viewpoint
    3. Compute the task's novelty (distance from centroid of previous tasks)
    4. If novelty > threshold → STOP (found breakthrough!)
    5. Otherwise → Continue with next expert

    Example:
        agent = NoveltyDrivenTaskAgent(novelty_threshold=0.4)
        result = await agent.run("Improve urban transportation")

        # result.breakthrough_task contains the novel task found
        # result.trajectory shows the exploration path
    """

    def __init__(
        self,
        novelty_threshold: float = 0.4,
        max_iterations: int = 20,
        ollama_base_url: str = "http://localhost:11435",
        llm_model: str = "qwen3:8b",
        embedding_model: str = "qwen3-embedding:4b",
        language: str = "en",
        data_dir: Optional[Path] = None,
        on_iteration: Optional[Callable[[GeneratedTask], None]] = None,
        temperature: float = 0.7
    ):
        """
        Args:
            novelty_threshold: Novelty score threshold for breakthrough (0.0-1.0)
            max_iterations: Maximum iterations before stopping
            ollama_base_url: Ollama API endpoint
            llm_model: Model for task generation
            embedding_model: Model for embeddings
            language: Language for prompts and experts ("en" or "zh")
            data_dir: Path to data directory for expert/domain files
            on_iteration: Callback function called after each iteration
            temperature: LLM temperature for generation
        """
        self.novelty_threshold = novelty_threshold
        self.max_iterations = max_iterations
        self.ollama_base_url = ollama_base_url
        self.llm_model = llm_model
        self.embedding_model = embedding_model
        self.language = language
        self.temperature = temperature
        self.on_iteration = on_iteration

        # Initialize providers
        self.expert_provider = ExpertProvider(data_dir, language)
        self.domain_provider = DomainProvider(data_dir, language)

        # Initialize novelty metrics
        self.novelty_metrics = NoveltyMetrics(
            similarity_threshold=0.7,
            jump_detection_enabled=True
        )

        # HTTP client
        self._client: Optional[httpx.AsyncClient] = None

    async def _get_client(self) -> httpx.AsyncClient:
        """Get or create HTTP client."""
        if self._client is None:
            self._client = httpx.AsyncClient(timeout=120.0)
        return self._client

    async def close(self):
        """Close HTTP client."""
        if self._client is not None:
            await self._client.aclose()
            self._client = None

    async def _generate_text(self, prompt: str) -> str:
        """Generate text using Ollama LLM."""
        client = await self._get_client()
        url = f"{self.ollama_base_url}/api/generate"

        # Add /no_think prefix for qwen models to disable thinking
        if self.llm_model.lower().startswith("qwen"):
            prompt = f"/no_think\n{prompt}"

        try:
            response = await client.post(url, json={
                "model": self.llm_model,
                "prompt": prompt,
                "stream": False,
                "options": {
                    "temperature": self.temperature
                }
            })
            response.raise_for_status()
            result = response.json()
            return result.get("response", "").strip()
        except Exception as e:
            logger.error(f"LLM generation error: {e}")
            raise

    async def _get_embedding(self, text: str) -> np.ndarray:
        """Get embedding vector for text."""
        client = await self._get_client()
        url = f"{self.ollama_base_url}/api/embed"

        try:
            response = await client.post(url, json={
                "model": self.embedding_model,
                "input": text
            })
            response.raise_for_status()
            result = response.json()
            return np.array(result["embeddings"][0])
        except Exception as e:
            logger.error(f"Embedding error: {e}")
            raise

    def _build_task_prompt(
        self,
        seed_problem: str,
        expert: dict,
        previous_tasks: List[str]
    ) -> str:
        """Build the prompt for task generation."""
        expert_name = expert.get("name", "Expert")
        expert_domain = expert.get("domain", "General")

        # Build context from previous tasks (if any)
        context = ""
        if previous_tasks:
            recent = previous_tasks[-3:]  # Last 3 tasks
            context = "\n\nPrevious suggestions (generate something DIFFERENT):\n"
            for t in recent:
                context += f"- {t}\n"

        if self.language == "zh":
            prompt = f"""你是一位 {expert_name}（{expert_domain}）。

给定问题：{seed_problem}

请从你的专业角度出发，提出一个独特的改进任务或探索方向。
这个任务应该结合你的专业知识，提供一个非传统但有价值的视角。
{context}
请直接给出任务描述，不要添加解释。任务应该具体、可行、且与众不同。

任务："""
        else:
            prompt = f"""You are a {expert_name} ({expert_domain}).

Given problem: {seed_problem}

From your professional perspective, propose a unique task or exploration direction to improve or innovate on this problem.
The task should leverage your domain expertise to provide an unconventional but valuable angle.
{context}
Provide just the task description without explanation. The task should be specific, actionable, and distinctive.

Task:"""

        return prompt

    async def _generate_task(
        self,
        seed_problem: str,
        expert: dict,
        previous_tasks: List[str]
    ) -> str:
        """Generate a task from an expert's perspective."""
        prompt = self._build_task_prompt(seed_problem, expert, previous_tasks)
        task = await self._generate_text(prompt)

        # Clean up the response
        task = task.strip()
        # Remove common prefixes
        for prefix in ["Task:", "任务:", "Here's", "I suggest", "Based on"]:
            if task.lower().startswith(prefix.lower()):
                task = task[len(prefix):].strip()

        return task

    async def run(
        self,
        seed_problem: str,
        used_experts: Optional[List[dict]] = None
    ) -> TaskGenerationResult:
        """
        Run the novelty-driven task generation loop.

        Args:
            seed_problem: The initial problem/challenge to explore
            used_experts: Optional list of experts to avoid (for multi-run scenarios)

        Returns:
            TaskGenerationResult with breakthrough task (if found) and full trajectory
        """
        # Reset state
        self.novelty_metrics.reset()

        result = TaskGenerationResult(
            seed_problem=seed_problem,
            start_time=datetime.now(timezone.utc).isoformat(),
            config={
                "novelty_threshold": self.novelty_threshold,
                "max_iterations": self.max_iterations,
                "llm_model": self.llm_model,
                "embedding_model": self.embedding_model,
                "language": self.language
            }
        )

        used_expert_names = set()
        if used_experts:
            used_expert_names = {e["name"] for e in used_experts}

        previous_tasks: List[str] = []

        logger.info(f"Starting novelty loop: '{seed_problem}' (threshold={self.novelty_threshold})")

        try:
            for iteration in range(self.max_iterations):
                # 1. Sample a random expert (avoid duplicates)
                attempts = 0
                expert = self.expert_provider.get_random_expert()
                while expert["name"] in used_expert_names and attempts < 10:
                    expert = self.expert_provider.get_random_expert()
                    attempts += 1
                used_expert_names.add(expert["name"])

                logger.info(f"Iteration {iteration + 1}: Expert = {expert['name']} ({expert['domain']})")

                # 2. Generate task
                task = await self._generate_task(seed_problem, expert, previous_tasks)
                previous_tasks.append(task)

                # 3. Get embedding
                embedding = await self._get_embedding(task)

                # 4. Compute novelty
                novelty = self.novelty_metrics.compute_novelty(embedding)
                self.novelty_metrics.add_embedding(embedding, novelty)

                # 5. Create task record
                generated_task = GeneratedTask(
                    task=task,
                    expert=expert["name"],
                    expert_domain=expert["domain"],
                    novelty_score=novelty.score,
                    iteration=iteration + 1,
                    is_breakthrough=novelty.score > self.novelty_threshold,
                    embedding=embedding
                )
                result.trajectory.append(generated_task)

                logger.info(f"  Task: {task[:80]}...")
                logger.info(f"  Novelty: {novelty.score:.4f} (threshold: {self.novelty_threshold})")

                # Callback
                if self.on_iteration:
                    self.on_iteration(generated_task)

                # 6. Check for breakthrough
                if novelty.score > self.novelty_threshold:
                    result.breakthrough_task = generated_task
                    result.terminated_by = "breakthrough"
                    result.total_iterations = iteration + 1
                    logger.info(f"  BREAKTHROUGH! Stopping after {iteration + 1} iterations")
                    break

            else:
                # Max iterations reached without breakthrough
                result.terminated_by = "max_iterations"
                result.total_iterations = self.max_iterations
                logger.info(f"Max iterations ({self.max_iterations}) reached without breakthrough")

                # Find the most novel task as a fallback
                if result.trajectory:
                    best_task = max(result.trajectory, key=lambda t: t.novelty_score)
                    best_task.is_breakthrough = True  # Mark as best found
                    result.breakthrough_task = best_task

        except Exception as e:
            logger.error(f"Error during generation: {e}")
            result.terminated_by = f"error: {str(e)}"
            result.total_iterations = len(result.trajectory)

        # Finalize
        result.end_time = datetime.now(timezone.utc).isoformat()
        result.novelty_trajectory = self.novelty_metrics.trajectory

        return result


# ============================================================================
# Alternative Termination Strategies
# ============================================================================

class ExhaustFrontierAgent(NoveltyDrivenTaskAgent):
    """
    Alternative strategy: Continue while novelty is high, stop when it drops.

    This explores the "novelty frontier" more thoroughly, finding multiple novel
    ideas before stopping when exploration becomes repetitive.
    """

    def __init__(
        self,
        exhaustion_threshold: float = 0.15,
        window_size: int = 3,
        min_iterations: int = 5,
        **kwargs
    ):
        """
        Args:
            exhaustion_threshold: Stop when recent average novelty drops below this
            window_size: Number of recent iterations to average
            min_iterations: Minimum iterations before checking exhaustion
            **kwargs: Passed to parent class
        """
        super().__init__(**kwargs)
        self.exhaustion_threshold = exhaustion_threshold
        self.window_size = window_size
        self.min_iterations = min_iterations

    async def run(self, seed_problem: str, **kwargs) -> TaskGenerationResult:
        """Override to use exhaustion-based termination."""
        # Reset state
        self.novelty_metrics.reset()

        result = TaskGenerationResult(
            seed_problem=seed_problem,
            start_time=datetime.now(timezone.utc).isoformat(),
            config={
                "strategy": "exhaust_frontier",
                "exhaustion_threshold": self.exhaustion_threshold,
                "window_size": self.window_size,
                "min_iterations": self.min_iterations,
                "max_iterations": self.max_iterations,
                "llm_model": self.llm_model
            }
        )

        used_expert_names = set()
        previous_tasks: List[str] = []
        novelty_history: List[float] = []

        try:
            for iteration in range(self.max_iterations):
                # Sample expert
                expert = self.expert_provider.get_random_expert()
                while expert["name"] in used_expert_names and len(used_expert_names) < 200:
                    expert = self.expert_provider.get_random_expert()
                used_expert_names.add(expert["name"])

                # Generate and evaluate
                task = await self._generate_task(seed_problem, expert, previous_tasks)
                previous_tasks.append(task)
                embedding = await self._get_embedding(task)
                novelty = self.novelty_metrics.compute_novelty(embedding)
                self.novelty_metrics.add_embedding(embedding, novelty)

                novelty_history.append(novelty.score)

                generated_task = GeneratedTask(
                    task=task,
                    expert=expert["name"],
                    expert_domain=expert["domain"],
                    novelty_score=novelty.score,
                    iteration=iteration + 1
                )
                result.trajectory.append(generated_task)

                if self.on_iteration:
                    self.on_iteration(generated_task)

                # Check exhaustion condition
                if iteration >= self.min_iterations:
                    recent_avg = np.mean(novelty_history[-self.window_size:])
                    if recent_avg < self.exhaustion_threshold:
                        result.terminated_by = f"exhaustion (avg={recent_avg:.3f})"
                        result.total_iterations = iteration + 1
                        break

            else:
                result.terminated_by = "max_iterations"
                result.total_iterations = self.max_iterations

            # Find all "novel" tasks
            novel_tasks = [t for t in result.trajectory if t.novelty_score > self.exhaustion_threshold]
            if novel_tasks:
                result.breakthrough_task = max(novel_tasks, key=lambda t: t.novelty_score)
                result.breakthrough_task.is_breakthrough = True

        except Exception as e:
            result.terminated_by = f"error: {str(e)}"
            result.total_iterations = len(result.trajectory)

        result.end_time = datetime.now(timezone.utc).isoformat()
        result.novelty_trajectory = self.novelty_metrics.trajectory

        return result


class CoverageTargetAgent(NoveltyDrivenTaskAgent):
    """
    Alternative strategy: Continue until N distinct clusters are covered.

    This ensures a diverse portfolio of ideas across different conceptual areas.
    """

    def __init__(
        self,
        target_clusters: int = 5,
        cluster_threshold: float = 0.7,
        **kwargs
    ):
        """
        Args:
            target_clusters: Target number of distinct clusters to find
            cluster_threshold: Similarity threshold for cluster membership
            **kwargs: Passed to parent class
        """
        super().__init__(**kwargs)
        self.target_clusters = target_clusters
        self.cluster_threshold = cluster_threshold

    def _count_clusters(self, embeddings: List[np.ndarray]) -> int:
        """Count distinct clusters using greedy clustering."""
        if not embeddings:
            return 0

        clusters = []
        for emb in embeddings:
            found_cluster = False
            for cluster_centroid in clusters:
                similarity = NoveltyMetrics.cosine_similarity(emb, cluster_centroid)
                if similarity >= self.cluster_threshold:
                    found_cluster = True
                    break

            if not found_cluster:
                clusters.append(emb)

        return len(clusters)

    async def run(self, seed_problem: str, **kwargs) -> TaskGenerationResult:
        """Override to use coverage-based termination."""
        self.novelty_metrics.reset()

        result = TaskGenerationResult(
            seed_problem=seed_problem,
            start_time=datetime.now(timezone.utc).isoformat(),
            config={
                "strategy": "coverage_target",
                "target_clusters": self.target_clusters,
                "cluster_threshold": self.cluster_threshold,
                "max_iterations": self.max_iterations
            }
        )

        used_expert_names = set()
        previous_tasks: List[str] = []
        all_embeddings: List[np.ndarray] = []

        try:
            for iteration in range(self.max_iterations):
                expert = self.expert_provider.get_random_expert()
                while expert["name"] in used_expert_names and len(used_expert_names) < 200:
                    expert = self.expert_provider.get_random_expert()
                used_expert_names.add(expert["name"])

                task = await self._generate_task(seed_problem, expert, previous_tasks)
                previous_tasks.append(task)
                embedding = await self._get_embedding(task)
                all_embeddings.append(embedding)

                novelty = self.novelty_metrics.compute_novelty(embedding)
                self.novelty_metrics.add_embedding(embedding, novelty)

                generated_task = GeneratedTask(
                    task=task,
                    expert=expert["name"],
                    expert_domain=expert["domain"],
                    novelty_score=novelty.score,
                    iteration=iteration + 1
                )
                result.trajectory.append(generated_task)

                if self.on_iteration:
                    self.on_iteration(generated_task)

                # Check coverage
                cluster_count = self._count_clusters(all_embeddings)
                if cluster_count >= self.target_clusters:
                    result.terminated_by = f"coverage ({cluster_count} clusters)"
                    result.total_iterations = iteration + 1
                    break

            else:
                final_clusters = self._count_clusters(all_embeddings)
                result.terminated_by = f"max_iterations ({final_clusters} clusters)"
                result.total_iterations = self.max_iterations

            # Find most novel task
            if result.trajectory:
                best_task = max(result.trajectory, key=lambda t: t.novelty_score)
                best_task.is_breakthrough = True
                result.breakthrough_task = best_task

        except Exception as e:
            result.terminated_by = f"error: {str(e)}"
            result.total_iterations = len(result.trajectory)

        result.end_time = datetime.now(timezone.utc).isoformat()
        result.novelty_trajectory = self.novelty_metrics.trajectory

        return result
