"""topic_memory_node.py — Conversation topic memory.
Issue #299

Subscribes to /social/conversation_text (std_msgs/String, JSON payload
{"person_id": "...", "text": "..."}), extracts key topics via stop-word
filtered keyword extraction, and maintains a per-person rolling topic
history.

On every message that yields at least one new keyword the node publishes
an updated topic snapshot on /saltybot/conversation_topics (std_msgs/String,
JSON) — enabling recall like "last time you mentioned coffee" or "you talked
about the weather with alice".

Published JSON format
─────────────────────
  {
    "person_id":     "alice",
    "recent_topics": ["coffee", "weather", "robot"],   // most-recent first
    "new_topics":    ["coffee"],                        // keywords added this turn
    "ts":            1234567890.123
  }

Keyword extraction pipeline
────────────────────────────
  1. Lowercase + tokenise on non-word characters
  2. Filter: length >= min_word_length, alphabetic only
  3. Remove stop words (built-in English list)
  4. Deduplicate within the utterance
  5. Take first max_keywords_per_msg tokens

Per-person storage
──────────────────
  Ordered list (insertion order), capped at max_topics_per_person.
  Duplicate keywords are promoted to the front (most-recent position).
  Persons not seen for prune_after_s seconds are pruned on next publish
  (set to 0 to disable pruning).

Parameters
──────────
  conversation_topic   (str, "/social/conversation_text")
  output_topic         (str, "/saltybot/conversation_topics")
  min_word_length      (int, 3)    minimum character length to keep
  max_keywords_per_msg (int, 10)   max keywords extracted per utterance
  max_topics_per_person(int, 30)   rolling window per person
  prune_after_s        (float, 1800.0)  forget persons idle this long (0=off)
"""

from __future__ import annotations

import json
import re
import string
import time
import threading
from typing import Dict, List, Optional

import rclpy
from rclpy.node import Node
from rclpy.qos import QoSProfile
from std_msgs.msg import String


# ── Stop-word list (English) ───────────────────────────────────────────────────

STOP_WORDS: frozenset = frozenset({
    "the", "a", "an", "and", "or", "but", "nor", "not", "so", "yet",
    "for", "with", "from", "into", "onto", "upon", "about", "above",
    "after", "before", "between", "during", "through", "under", "over",
    "at", "by", "in", "of", "on", "to", "up", "as",
    "is", "are", "was", "were", "be", "been", "being",
    "have", "has", "had", "do", "does", "did",
    "will", "would", "could", "should", "may", "might", "shall", "can",
    "it", "its", "this", "that", "these", "those",
    "i", "me", "my", "myself", "we", "our", "ours",
    "you", "your", "yours", "he", "him", "his", "she", "her", "hers",
    "they", "them", "their", "theirs",
    "what", "which", "who", "whom", "when", "where", "why", "how",
    "all", "each", "every", "both", "few", "more", "most",
    "other", "some", "such", "no", "only", "own", "same",
    "than", "then", "too", "very", "just", "also",
    "get", "got", "say", "said", "know", "think", "go", "going", "come",
    "like", "want", "see", "take", "make", "give", "look",
    "yes", "yeah", "okay", "ok", "oh", "ah", "um", "uh", "well",
    "now", "here", "there", "hi", "hey", "hello",
})

_PUNCT_RE = re.compile(r"[^\w\s]")


# ── Keyword extraction ────────────────────────────────────────────────────────

def extract_keywords(text: str,
                     min_length: int = 3,
                     max_keywords: int = 10) -> List[str]:
    """Return a deduplicated list of meaningful keywords from *text*.

    Steps: lowercase -> strip punctuation -> split -> filter stop words &
    length -> deduplicate -> cap at max_keywords.
    """
    cleaned = _PUNCT_RE.sub(" ", text.lower())
    seen: dict = {}            # ordered-set via insertion-order dict
    for tok in cleaned.split():
        tok = tok.strip(string.punctuation + "_")
        if (len(tok) >= min_length
                and tok.isalpha()
                and tok not in STOP_WORDS
                and tok not in seen):
            seen[tok] = None
            if len(seen) >= max_keywords:
                break
    return list(seen)


# ── Per-person topic memory ───────────────────────────────────────────────────

class PersonTopicMemory:
    """Rolling, deduplicated topic list for one person."""

    def __init__(self, max_topics: int = 30) -> None:
        self._max       = max_topics
        self._topics: List[str] = []   # oldest -> newest order
        self._topic_set: set   = set()
        self.last_updated: float = 0.0

    def add(self, keywords: List[str]) -> List[str]:
        """Add *keywords*; promote duplicates to front, evict oldest over cap.

        Returns the list of newly added (previously unseen) keywords.
        """
        added: List[str] = []
        for kw in keywords:
            if kw in self._topic_set:
                # Promote to most-recent position
                self._topics.remove(kw)
                self._topics.append(kw)
            else:
                self._topics.append(kw)
                self._topic_set.add(kw)
                added.append(kw)
            # Trim oldest if over cap
            while len(self._topics) > self._max:
                evicted = self._topics.pop(0)
                self._topic_set.discard(evicted)
        self.last_updated = time.monotonic()
        return added

    @property
    def recent_topics(self) -> List[str]:
        """Most-recent topics first."""
        return list(reversed(self._topics))

    def __len__(self) -> int:
        return len(self._topics)


# ── ROS2 node ─────────────────────────────────────────────────────────────────

class TopicMemoryNode(Node):
    """Extracts and remembers conversation topics per person."""

    def __init__(self) -> None:
        super().__init__("topic_memory_node")

        self.declare_parameter("conversation_topic",    "/social/conversation_text")
        self.declare_parameter("output_topic",          "/saltybot/conversation_topics")
        self.declare_parameter("min_word_length",       3)
        self.declare_parameter("max_keywords_per_msg",  10)
        self.declare_parameter("max_topics_per_person", 30)
        self.declare_parameter("prune_after_s",         1800.0)

        conv_topic     = self.get_parameter("conversation_topic").value
        out_topic      = self.get_parameter("output_topic").value
        self._min_len  = self.get_parameter("min_word_length").value
        self._max_kw   = self.get_parameter("max_keywords_per_msg").value
        self._max_tp   = self.get_parameter("max_topics_per_person").value
        self._prune_s  = self.get_parameter("prune_after_s").value

        # person_id -> PersonTopicMemory
        self._memory: Dict[str, PersonTopicMemory] = {}
        self._lock = threading.Lock()

        qos = QoSProfile(depth=10)
        self._pub = self.create_publisher(String, out_topic, qos)
        self._sub = self.create_subscription(
            String, conv_topic, self._on_conversation, qos
        )

        self.get_logger().info(
            f"TopicMemoryNode ready "
            f"(min_len={self._min_len}, max_kw={self._max_kw}, "
            f"max_topics={self._max_tp}, prune_after={self._prune_s}s)"
        )

    # ── Subscription ───────────────────────────────────────────────────────

    def _on_conversation(self, msg: String) -> None:
        try:
            payload = json.loads(msg.data)
            person_id: str = str(payload.get("person_id", "unknown"))
            text: str      = str(payload.get("text", ""))
        except (json.JSONDecodeError, AttributeError) as exc:
            self.get_logger().warn(f"Bad conversation_text payload: {exc}")
            return

        if not text.strip():
            return

        keywords = extract_keywords(text, self._min_len, self._max_kw)
        if not keywords:
            return

        with self._lock:
            self._prune_stale()
            if person_id not in self._memory:
                self._memory[person_id] = PersonTopicMemory(self._max_tp)
            mem = self._memory[person_id]
            new_topics = mem.add(keywords)
            recent = mem.recent_topics

        out = String()
        out.data = json.dumps({
            "person_id":     person_id,
            "recent_topics": recent,
            "new_topics":    new_topics,
            "ts":            time.time(),
        })
        self._pub.publish(out)

        if new_topics:
            self.get_logger().info(
                f"[{person_id}] new topics: {new_topics} | "
                f"memory: {recent[:5]}{'...' if len(recent) > 5 else ''}"
            )

    # ── Helpers ────────────────────────────────────────────────────────────

    def _prune_stale(self) -> None:
        """Remove persons not seen for prune_after_s seconds (call under lock)."""
        if self._prune_s <= 0:
            return
        now = time.monotonic()
        stale = [pid for pid, m in self._memory.items()
                 if (now - m.last_updated) > self._prune_s]
        for pid in stale:
            del self._memory[pid]
            self.get_logger().info(f"Pruned stale person: {pid}")

    def get_memory(self, person_id: str) -> Optional[PersonTopicMemory]:
        """Return topic memory for a person (None if not seen)."""
        with self._lock:
            return self._memory.get(person_id)

    def all_persons(self) -> Dict[str, List[str]]:
        """Return {person_id: recent_topics} snapshot for all known persons."""
        with self._lock:
            return {pid: m.recent_topics for pid, m in self._memory.items()}


def main(args=None) -> None:
    rclpy.init(args=args)
    node = TopicMemoryNode()
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.shutdown()
