from __future__ import annotations

import json
import math
import os
import re
from datetime import UTC, datetime

from persistence import secure_atomic_write

_DEFAULT_INTEREST_SCORE = 0.5
_DEFAULT_CONFIDENCE = 0.5


def _clamp(value: float, low: float = 0.0, high: float = 1.0) -> float:
    return max(low, min(high, float(value)))


def _normalize_topic(topic: str) -> str:
    text = (topic or "").strip().lower()
    if not text:
        return ""
    return re.sub(r"\s+", " ", text)[:120]


def _utc_now_iso() -> str:
    return datetime.now(UTC).replace(microsecond=0).isoformat()


def _safe_load_json(path: str, default: dict) -> dict:
    if not path or not os.path.isfile(path):
        return dict(default)
    try:
        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)
        return data if isinstance(data, dict) else dict(default)
    except Exception:
        return dict(default)


def _safe_write_json(path: str, payload: dict) -> None:
    parent = os.path.dirname(path)
    if parent:
        os.makedirs(parent, exist_ok=True)
    secure_atomic_write(path, json.dumps(payload, indent=2, ensure_ascii=True))
    try:
        os.chmod(path, 0o600)
    except OSError:
        pass


def _get_workspace(workspace: str | None = None) -> str:
    if workspace:
        return os.path.abspath(os.path.expanduser(workspace))
    from core import get_workspace_path

    return get_workspace_path()


def _safe_label(topic: str) -> str:
    return re.sub(r"[^a-zA-Z0-9_-]", "_", (topic or "").lower())[:40]


class InterestModel:
    def __init__(
        self,
        workspace: str | None = None,
        decay: float = 0.9,
        path: str | None = None,
    ) -> None:
        self.workspace = _get_workspace(workspace)
        self.decay = _clamp(decay)
        self.path = path or os.path.join(self.workspace, "memory", "interest_model.json")
        raw = _safe_load_json(self.path, default={"topics": {}})
        self.topics: dict[str, dict] = {}
        for key, value in (raw.get("topics") or {}).items():
            norm = _normalize_topic(str(key))
            if not norm:
                continue
            if isinstance(value, (int, float)):
                self.topics[norm] = {
                    "score": _clamp(float(value)),
                    "updates": 1,
                    "last_updated": "",
                    "source": "user",
                }
                continue
            if isinstance(value, dict):
                self.topics[norm] = {
                    "score": _clamp(float(value.get("score", _DEFAULT_INTEREST_SCORE))),
                    "updates": max(0, int(value.get("updates", 0))),
                    "last_updated": str(value.get("last_updated", "")),
                    "source": str(value.get("source", "user")),
                }

    def _save(self) -> None:
        if len(self.topics) > 50:
            by_source: dict[str, list[tuple[str, dict]]] = {}
            for k, v in self.topics.items():
                src = str(v.get("source", "user"))
                by_source.setdefault(src, []).append((k, v))
            kept: dict[str, dict] = {}
            for src, items in by_source.items():
                items.sort(key=lambda x: float(x[1].get("score", 0)), reverse=True)
                for k, v in items[:50]:
                    kept[k] = v
            self.topics = kept
        _safe_write_json(
            self.path,
            {
                "topics": self.topics,
            },
        )

    def update(self, topic: str, engagement: float, source: str = "user") -> float:
        norm = _normalize_topic(topic)
        if not norm:
            return _DEFAULT_INTEREST_SCORE
        entry = self.topics.get(
            norm,
            {
                "score": _DEFAULT_INTEREST_SCORE,
                "updates": 0,
                "last_updated": "",
                "source": source,
            },
        )
        old_score = _clamp(float(entry.get("score", _DEFAULT_INTEREST_SCORE)))
        new_score = old_score * self.decay + _clamp(engagement) * (1.0 - self.decay)
        entry["score"] = _clamp(new_score)
        entry["updates"] = max(0, int(entry.get("updates", 0))) + 1
        entry["last_updated"] = _utc_now_iso()
        existing_source = str(entry.get("source", "user"))
        if existing_source != source and existing_source != "shared":
            entry["source"] = "shared"
        elif existing_source == "shared":
            pass
        else:
            entry["source"] = source
        self.topics[norm] = entry
        self._save()
        return float(entry["score"])

    def get_score(self, topic: str, default: float = _DEFAULT_INTEREST_SCORE) -> float:
        norm = _normalize_topic(topic)
        if not norm:
            return _clamp(default)
        entry = self.topics.get(norm)
        if not isinstance(entry, dict):
            return _clamp(default)
        return _clamp(float(entry.get("score", default)))

    def get_top_topics(self, n: int = 3) -> list[tuple[str, float]]:
        size = max(0, int(n))
        ordered = sorted(
            ((k, _clamp(float(v.get("score", _DEFAULT_INTEREST_SCORE)))) for k, v in self.topics.items()),
            key=lambda item: item[1],
            reverse=True,
        )
        return ordered[:size]

    def get_shared_interests(self, limit: int = 5) -> list[tuple[str, float]]:
        """Return topics with source='shared', sorted by score descending."""
        shared = [
            (k, _clamp(float(v.get("score", _DEFAULT_INTEREST_SCORE))))
            for k, v in self.topics.items()
            if str(v.get("source", "")) == "shared"
        ]
        shared.sort(key=lambda item: item[1], reverse=True)
        return shared[:max(0, limit)]

    def get_frequency(self, topic: str) -> float:
        norm = _normalize_topic(topic)
        if not norm:
            return 0.0
        max_updates = max([int(v.get("updates", 0)) for v in self.topics.values()] + [0])
        if max_updates <= 0:
            return 0.0
        cur_updates = int((self.topics.get(norm) or {}).get("updates", 0))
        return _clamp(float(cur_updates) / float(max_updates))

    def decay_all(self, factor: float | None = None) -> int:
        decay_factor = _clamp(self.decay if factor is None else factor)
        updated = 0
        for key, value in self.topics.items():
            old_score = _clamp(float(value.get("score", _DEFAULT_INTEREST_SCORE)))
            value["score"] = _clamp(0.5 + (old_score - 0.5) * decay_factor)
            value["last_updated"] = _utc_now_iso()
            self.topics[key] = value
            updated += 1
        if updated:
            self._save()
        return updated


class KnowledgeGapTracker:
    """Topic answer-confidence scores persisted in ``memory/knowledge_gaps.json``.

    Unanswered query misses live in ``memory/knowledge_gaps.jsonl`` (see
    ``tools.knowledge_tools._record_knowledge_gap`` / ``read_knowledge_gaps``).
    """
    def __init__(self, workspace: str | None = None, path: str | None = None) -> None:
        self.workspace = _get_workspace(workspace)
        self.path = path or os.path.join(self.workspace, "memory", "knowledge_gaps.json")
        raw = _safe_load_json(self.path, default={"topics": {}})
        self.topics: dict[str, dict] = {}
        for key, value in (raw.get("topics") or {}).items():
            norm = _normalize_topic(str(key))
            if not norm or not isinstance(value, dict):
                continue
            self.topics[norm] = {
                "confidence": _clamp(float(value.get("confidence", _DEFAULT_CONFIDENCE))),
                "last_answered": str(value.get("last_answered", "")),
                "answer_count": max(0, int(value.get("answer_count", 0))),
            }

    def _save(self) -> None:
        if len(self.topics) > 50:
            sorted_topics = sorted(
                self.topics.items(),
                key=lambda x: float(x[1].get("confidence", 0.5)),
            )
            self.topics = dict(sorted_topics[:50])
        _safe_write_json(
            self.path,
            {
                "topics": self.topics,
            },
        )

    def record_answer(self, topic: str, confidence: float) -> float:
        norm = _normalize_topic(topic)
        if not norm:
            return _DEFAULT_CONFIDENCE
        entry = self.topics.get(
            norm,
            {
                "confidence": _DEFAULT_CONFIDENCE,
                "last_answered": "",
                "answer_count": 0,
            },
        )
        prev_count = max(0, int(entry.get("answer_count", 0)))
        prev_conf = _clamp(float(entry.get("confidence", _DEFAULT_CONFIDENCE)))
        new_conf = _clamp(confidence)
        merged = ((prev_conf * prev_count) + new_conf) / float(prev_count + 1)
        entry["confidence"] = _clamp(merged)
        entry["answer_count"] = prev_count + 1
        entry["last_answered"] = _utc_now_iso()
        self.topics[norm] = entry
        self._save()
        return float(entry["confidence"])

    def get_confidence(self, topic: str, default: float = _DEFAULT_CONFIDENCE) -> float:
        norm = _normalize_topic(topic)
        if not norm:
            return _clamp(default)
        entry = self.topics.get(norm)
        if not isinstance(entry, dict):
            return _clamp(default)
        return _clamp(float(entry.get("confidence", default)))

    def get_low_confidence_topics(self, threshold: float = 0.5) -> list[tuple[str, float]]:
        th = _clamp(threshold)
        out: list[tuple[str, float]] = []
        for topic, entry in self.topics.items():
            conf = _clamp(float(entry.get("confidence", _DEFAULT_CONFIDENCE)))
            if conf < th:
                out.append((topic, conf))
        out.sort(key=lambda item: item[1])
        return out


def time_since_last_research(topic: str, prefetch_dir: str) -> float:
    """Return age in hours for the latest prefetched file matching topic.

    Returns math.inf if no prior prefetched research exists for this topic.
    """
    if not prefetch_dir or not os.path.isdir(prefetch_dir):
        return math.inf
    safe_label = _safe_label(topic)
    if not safe_label:
        return math.inf
    newest_mtime = 0.0
    try:
        for fname in os.listdir(prefetch_dir):
            if safe_label not in fname.lower():
                continue
            fpath = os.path.join(prefetch_dir, fname)
            try:
                newest_mtime = max(newest_mtime, os.path.getmtime(fpath))
            except OSError:
                continue
    except OSError:
        return math.inf
    if newest_mtime <= 0:
        return math.inf
    return max(0.0, (datetime.now().timestamp() - newest_mtime) / 3600.0)


def calculate_curiosity_score(
    topic: str,
    interest_model: InterestModel,
    gap_tracker: KnowledgeGapTracker,
    prefetch_dir: str,
    half_life_hours: float = 48.0,
) -> float:
    """Calculate curiosity score in [0,1] for a topic."""
    novelty = 1.0 - interest_model.get_frequency(topic)
    relevance = interest_model.get_score(topic)
    uncertainty = 1.0 - gap_tracker.get_confidence(topic)

    age_hours = time_since_last_research(topic, prefetch_dir)
    if math.isinf(age_hours):
        recency = 1.0
    else:
        safe_half_life = max(1.0, float(half_life_hours))
        recency = 1.0 - math.exp(-age_hours / safe_half_life)

    return _clamp(novelty * relevance * uncertainty * recency)
