"""ConceptInventor — generating novel concepts by cross-pollinating domains.

v0.22.0: The Creative Mind

Divergent thinking is the cognitive capacity to generate many associations
from a single starting point — to cross conceptual boundaries and find
unexpected connections between seemingly unrelated domains. This capacity
underlies creative insight: the sudden recognition that a solution from one
domain solves a problem in another. It is operationalised here by mining each
memory domain for its characteristic rare tokens and then systematically
pairing tokens across domains to generate candidate concepts.

ConceptInventor builds a per-domain map of rare tokens — those appearing in
fewer than 20% of that domain's memories. It then pairs tokens from *different*
domains, computing an originality score proportional to how rare each token
is within its own domain. Pairs exceeding min_originality are elevated to
InventedConcept objects, each with a template-generated description that
suggests how the two concepts might synthesise. The result is a structured
creative prompt: a list of novel cross-domain concepts ranked by originality.

Biological analogue: divergent thinking (Guilford 1967) — the dimension of
creativity characterised by fluency of association across categories; default
mode network in creative ideation (Beaty et al. 2016) — the DMN is active
during unconstrained, associative thought; conceptual blending (Fauconnier &
Turner 2002) — mental spaces from different domains merge to form novel
integrated meanings; remote associates as creative insight (Mednick 1962) —
creativity as forming associations between distant semantic nodes; analogical
reasoning across structural mappings (Gentner et al. 2009).
"""

from __future__ import annotations

import time
import uuid
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from typing import Any, Optional


_STOP_WORDS: frozenset[str] = frozenset({
    "a", "an", "the", "and", "or", "in", "on", "to", "for", "of", "with",
    "by", "from", "is", "was", "are", "be", "it", "this", "that", "as",
    "at", "but", "not", "so", "if", "do", "did", "has", "have", "had",
    "will", "would", "could", "should", "may", "might",
})


# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------


@dataclass
class InventedConcept:
    """A novel concept generated by pairing tokens from two different domains."""

    id: str                  # prefixed "inv_"
    token_a: str             # concept token from domain_a
    domain_a: str
    token_b: str             # concept token from domain_b
    domain_b: str
    description: str         # template-generated synthesis description
    originality_score: float  # 0..1
    created_at: float

    def summary(self) -> str:
        return (
            f"InventedConcept [{self.domain_a} × {self.domain_b}]  "
            f"originality={self.originality_score:.3f}\n"
            f"  {self.id[:12]}: {self.description[:80]}"
        )


@dataclass
class InventionReport:
    """Result of a ConceptInventor.invent() call."""

    total_concepts: int
    concepts: list[InventedConcept]         # sorted by originality desc
    domain_pairs: list[tuple[str, str]]
    mean_originality: float
    duration_seconds: float

    def summary(self) -> str:
        pairs_str = ", ".join(f"{a}×{b}" for a, b in self.domain_pairs[:5])
        lines = [
            f"InventionReport: {self.total_concepts} concepts  "
            f"pairs=[{pairs_str}]  "
            f"mean_originality={self.mean_originality:.3f}  "
            f"in {self.duration_seconds:.2f}s",
        ]
        for c in self.concepts[:5]:
            lines.append(f"  {c.summary()}")
        return "\n".join(lines)


# ---------------------------------------------------------------------------
# ConceptInventor
# ---------------------------------------------------------------------------


class ConceptInventor:
    """Generates novel concepts by cross-pollinating rare tokens across domains.

    Parameters
    ----------
    memory:
        The :class:`HierarchicalMemory` instance.
    min_originality:
        Minimum originality score to include a concept (default 0.3).
    max_concepts:
        Maximum number of :class:`InventedConcept` objects to return
        (default 10).
    """

    def __init__(
        self,
        memory: Any,
        min_originality: float = 0.3,
        max_concepts: int = 10,
    ) -> None:
        self.memory = memory
        self.min_originality = min_originality
        self.max_concepts = max_concepts
        self._concepts: list[InventedConcept] = []

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def invent(self, n: int = 8) -> InventionReport:
        """Generate novel cross-domain concepts.

        Args:
            n: Maximum number of concepts to generate (default 8).

        Returns:
            :class:`InventionReport` with concepts sorted by originality.
        """
        t0 = time.time()
        all_items = self._collect_all()

        # Group items by domain
        domain_items: dict[str, list[Any]] = defaultdict(list)
        for item in all_items:
            dom = getattr(item.experience, "domain", None) or "general"
            domain_items[dom].append(item)

        # For each domain, build per-token doc-frequency and collect rare tokens
        domain_rare_tokens: dict[str, list[tuple[str, int, int]]] = {}
        # value = list of (token, domain_freq, n_domain_docs)
        domain_full_freq: dict[str, Counter] = {}  # full freq for normalisation
        for dom, items in domain_items.items():
            freq: Counter = Counter()
            n_docs = len(items)
            for item in items:
                content = getattr(item.experience, "content", "") or ""
                tokens = self._tokenise(content)
                for tok in set(tokens):
                    freq[tok] += 1
            domain_full_freq[dom] = freq
            # rare = appears in <= 20% of domain memories
            threshold = max(1, int(n_docs * 0.20))
            rare = [
                (tok, count, n_docs)
                for tok, count in freq.items()
                if count <= threshold
            ]
            if rare:
                domain_rare_tokens[dom] = rare

        # Cross-pair tokens from different domains
        domains = list(domain_rare_tokens.keys())
        candidates: list[InventedConcept] = []
        seen_pairs: set[frozenset[str]] = set()

        # Compute max_freq per domain from the FULL domain frequency (not just rare),
        # so the normalisation is meaningful even when all rare tokens have count=1.
        domain_max_freq: dict[str, int] = {
            dom: max(domain_full_freq[dom].values(), default=1)
            for dom in domain_rare_tokens
        }

        for i, dom_a in enumerate(domains):
            for dom_b in domains[i + 1:]:
                pair_key = frozenset([dom_a, dom_b])
                if pair_key in seen_pairs:
                    continue
                seen_pairs.add(pair_key)

                tokens_a = domain_rare_tokens[dom_a]
                tokens_b = domain_rare_tokens[dom_b]
                max_freq_a = domain_max_freq[dom_a]
                max_freq_b = domain_max_freq[dom_b]

                for tok_a, freq_a, _ in tokens_a[:10]:  # cap per domain
                    for tok_b, freq_b, _ in tokens_b[:10]:
                        originality = (
                            (1 - freq_a / max_freq_a) * 0.5
                            + (1 - freq_b / max_freq_b) * 0.5
                        )
                        originality = round(min(1.0, max(0.0, originality)), 4)
                        if originality < self.min_originality:
                            continue
                        concept_id = "inv_" + uuid.uuid4().hex[:8]
                        description = (
                            f"What if {tok_a} (from {dom_a}) could {tok_b}? "
                            f"A {dom_a}-{dom_b} synthesis that applies "
                            f"{tok_a} principles to {tok_b} contexts."
                        )
                        candidates.append(InventedConcept(
                            id=concept_id,
                            token_a=tok_a,
                            domain_a=dom_a,
                            token_b=tok_b,
                            domain_b=dom_b,
                            description=description,
                            originality_score=originality,
                            created_at=time.time(),
                        ))

        # Sort and cap
        candidates.sort(key=lambda c: c.originality_score, reverse=True)
        top_n = min(n, self.max_concepts)
        self._concepts = candidates[:top_n]

        domain_pairs = list({
            (c.domain_a, c.domain_b) for c in self._concepts
        })

        mean_orig = (
            sum(c.originality_score for c in self._concepts) / len(self._concepts)
            if self._concepts else 0.0
        )

        return InventionReport(
            total_concepts=len(self._concepts),
            concepts=self._concepts,
            domain_pairs=domain_pairs,
            mean_originality=round(mean_orig, 4),
            duration_seconds=time.time() - t0,
        )

    def concepts_for_domain(self, domain: str) -> list[InventedConcept]:
        """Return concepts that involve the given domain.

        Args:
            domain: Domain name to filter by.
        """
        return [
            c for c in self._concepts
            if c.domain_a == domain or c.domain_b == domain
        ]

    def best_concept(self, goal_description: str) -> Optional[InventedConcept]:
        """Return the concept most relevant to a goal description (Jaccard).

        Args:
            goal_description: Natural-language description of the goal.

        Returns:
            Best-matching :class:`InventedConcept`, or ``None`` if no concepts.
        """
        if not self._concepts:
            return None
        goal_tokens = set(self._tokenise(goal_description))
        if not goal_tokens:
            return self._concepts[0] if self._concepts else None

        def jaccard(concept: InventedConcept) -> float:
            concept_tokens = set(
                self._tokenise(concept.description)
                + [concept.token_a, concept.token_b,
                   concept.domain_a, concept.domain_b]
            )
            if not concept_tokens:
                return 0.0
            inter = len(goal_tokens & concept_tokens)
            union = len(goal_tokens | concept_tokens)
            return inter / union if union else 0.0

        return max(self._concepts, key=jaccard)

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

    def _tokenise(self, text: str) -> list[str]:
        """Extract meaningful tokens from text."""
        return [
            w.strip(".,!?;:\"'()").lower()
            for w in text.split()
            if len(w.strip(".,!?;:\"'()")) >= 4
            and w.strip(".,!?;:\"'()").lower() not in _STOP_WORDS
        ]

    def _collect_all(self) -> list[Any]:
        items: list[Any] = []
        for tier in (self.memory.working, self.memory.short_term):
            items.extend(tier)
        for tier in (self.memory.long_term, self.memory.semantic):
            items.extend(tier.values())
        return items
