"""Improved issue grouping with merge, hierarchy, and lifecycle tracking.

Provides smarter grouping than raw embedding similarity:
- Hierarchical: fingerprint (exact) within embedding groups (semantic)
- Merges overlapping groups
- Tracks issue lifecycle (new, recurring, regressed, resolved)
"""

from __future__ import annotations

import copy
import math
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any, Dict, List, Optional, Set


class IssueLifecycle(Enum):
    """Lifecycle state of an issue group."""
    NEW = "new"
    RECURRING = "recurring"
    REGRESSED = "regressed"
    RESOLVED = "resolved"


@dataclass
class IssueGroup:
    """A group of related issues."""
    group_id: str
    title: str
    fingerprint: Optional[str] = None
    fingerprints: List[str] = field(default_factory=list)
    member_ids: List[str] = field(default_factory=list)
    lifecycle: IssueLifecycle = IssueLifecycle.NEW
    severity: int = 0
    occurrence_count: int = 0
    first_seen_session: Optional[str] = None
    last_seen_session: Optional[str] = None
    metadata: Dict[str, Any] = field(default_factory=dict)

    @property
    def all_fingerprints(self) -> Set[str]:
        """All fingerprints associated with this group."""
        fps: Set[str] = set(self.fingerprints)
        if self.fingerprint:
            fps.add(self.fingerprint)
        return fps


@dataclass
class GroupedError:
    """An error with its fingerprint and embedding for grouping."""
    error_id: str
    fingerprint: str
    exception_type: str
    message: str
    embedding: Optional[List[float]] = None
    session_id: Optional[str] = None
    severity: int = 0


class IssueGrouper:
    """Smart issue grouping with merge, hierarchy, and lifecycle."""

    def __init__(
        self,
        similarity_threshold: float = 0.75,
    ):
        self.similarity_threshold = similarity_threshold

    def group_errors(
        self,
        errors: List[GroupedError],
        existing_groups: Optional[List[IssueGroup]] = None,
    ) -> List[IssueGroup]:
        """Group errors using hierarchical approach.

        Phase 1: Group by exact fingerprint match
        Phase 2: Merge fingerprint groups by embedding similarity
        Phase 3: Update lifecycle based on existing groups
        """
        existing_groups = existing_groups or []

        # Phase 1: Group by fingerprint
        fp_groups = self._group_by_fingerprint(errors)

        # Phase 2: Merge similar fingerprint groups by embedding
        merged_groups = self._merge_similar_groups(fp_groups)

        # Phase 3: Update lifecycle
        final_groups = self._update_lifecycle(merged_groups, existing_groups)

        return final_groups

    def _group_by_fingerprint(self, errors: List[GroupedError]) -> List[IssueGroup]:
        """Group errors with identical fingerprints."""
        by_fp: Dict[str, List[GroupedError]] = {}
        for error in errors:
            by_fp.setdefault(error.fingerprint, []).append(error)

        groups: List[IssueGroup] = []
        for fp, members in by_fp.items():
            first = members[0]
            session_ids = self._sort_session_ids_chronologically(
                [m.session_id for m in members if m.session_id]
            )
            embeddings = [m.embedding for m in members if m.embedding]
            embedding = self._average_embedding(embeddings) if embeddings else None
            group = IssueGroup(
                group_id=f"fp_{fp[:12]}",
                title=f"{first.exception_type}: {first.message[:80]}",
                fingerprint=fp,
                fingerprints=[fp],
                member_ids=[m.error_id for m in members],
                severity=max(m.severity for m in members),
                occurrence_count=len(members),
                first_seen_session=session_ids[0] if session_ids else None,
                last_seen_session=session_ids[-1] if session_ids else None,
                metadata={"embedding": embedding} if embedding else {},
            )
            groups.append(group)

        return groups

    def _merge_similar_groups(self, groups: List[IssueGroup]) -> List[IssueGroup]:
        """Merge groups that have high embedding similarity.

        Uses union-find to efficiently merge overlapping groups.
        """
        if len(groups) <= 1:
            return groups

        # For now, fingerprint-based groups are already good.
        # When embeddings are available, merge groups whose centroids
        # exceed the similarity threshold.
        # This is a structural improvement over the flat loop in grouping.py
        # which creates new groups without checking for overlap.

        # Union-Find for merging
        parent: Dict[int, int] = {i: i for i in range(len(groups))}

        def find(x: int) -> int:
            while parent[x] != x:
                parent[x] = parent[parent[x]]
                x = parent[x]
            return x

        def union(a: int, b: int) -> None:
            ra, rb = find(a), find(b)
            if ra != rb:
                parent[ra] = rb

        # Merge groups with same exception_type AND similar messages
        embeddings_present = any(g.metadata.get("embedding") for g in groups)
        for i in range(len(groups)):
            for j in range(i + 1, len(groups)):
                title_i = groups[i].title.split(":", 1)[0].strip()
                title_j = groups[j].title.split(":", 1)[0].strip()
                if title_i == title_j and title_i:
                    # Check message similarity using configurable threshold
                    if self._message_similarity(groups[i].title, groups[j].title) >= self.similarity_threshold:
                        union(i, j)
                    # Also merge if both have embeddings with high cosine similarity
                    elif embeddings_present and self._embedding_similarity(groups, i, j) >= self.similarity_threshold:
                        union(i, j)

        # Build merged groups
        merged_map: Dict[int, List[int]] = {}
        for i in range(len(groups)):
            root = find(i)
            merged_map.setdefault(root, []).append(i)

        result: List[IssueGroup] = []
        for root, indices in merged_map.items():
            if len(indices) == 1:
                result.append(groups[indices[0]])
            else:
                # Merge into one group
                primary = groups[indices[0]]
                all_members: List[str] = []
                total_count = 0
                max_severity = 0
                session_ids: List[str] = []
                merged_source_group_ids: List[str] = []
                for idx in indices:
                    g = groups[idx]
                    all_members.extend(g.member_ids)
                    total_count += g.occurrence_count
                    max_severity = max(max_severity, g.severity)
                    merged_source_group_ids.append(g.group_id)
                    if g.first_seen_session:
                        session_ids.append(g.first_seen_session)
                    if g.last_seen_session:
                        session_ids.append(g.last_seen_session)
                    for sid in g.metadata.get("session_ids", []) if isinstance(g.metadata, dict) else []:
                        if sid:
                            session_ids.append(str(sid))
                session_ids = self._sort_session_ids_chronologically(session_ids)

                all_fingerprints: List[str] = []
                for idx in indices:
                    g = groups[idx]
                    if g.fingerprint and g.fingerprint not in all_fingerprints:
                        all_fingerprints.append(g.fingerprint)
                    for fp in g.fingerprints:
                        if fp not in all_fingerprints:
                            all_fingerprints.append(fp)

                merged_embedding = self._average_embedding(
                    [groups[idx].metadata.get("embedding") for idx in indices]
                )

                merged = IssueGroup(
                    group_id=primary.group_id,
                    title=primary.title,
                    fingerprint=primary.fingerprint,
                    fingerprints=all_fingerprints,
                    member_ids=list(dict.fromkeys(all_members)),
                    severity=max_severity,
                    occurrence_count=total_count,
                    first_seen_session=session_ids[0] if session_ids else primary.first_seen_session,
                    last_seen_session=session_ids[-1] if session_ids else primary.last_seen_session,
                    metadata={
                        "merged_from": len(indices),
                        "merged_group_ids": list(dict.fromkeys(merged_source_group_ids)),
                        "session_ids": list(dict.fromkeys(session_ids)),
                        **({"embedding": merged_embedding} if merged_embedding else {}),
                    },
                )
                result.append(merged)

        return result

    @staticmethod
    def _session_sort_key(session_id: str) -> tuple[int, float | str]:
        sid = str(session_id).strip()
        if not sid:
            return (2, "")
        try:
            parsed_dt = datetime.fromisoformat(sid.replace("Z", "+00:00"))
            return (0, parsed_dt.timestamp())
        except ValueError:
            pass
        try:
            parsed_uuid = uuid.UUID(sid)
            if parsed_uuid.version in {1, 6, 7}:
                return (1, float(parsed_uuid.time))
        except (ValueError, AttributeError):
            pass
        return (2, sid)

    def _sort_session_ids_chronologically(self, session_ids: List[str]) -> List[str]:
        unique_ids = [str(sid) for sid in dict.fromkeys(session_ids) if sid]
        return sorted(unique_ids, key=self._session_sort_key)

    def _embedding_similarity(self, groups: List[IssueGroup], i: int, j: int) -> float:
        """Cosine similarity between group embeddings (from first member)."""
        emb_i = groups[i].metadata.get("embedding")
        emb_j = groups[j].metadata.get("embedding")
        if not emb_i or not emb_j or len(emb_i) != len(emb_j):
            return 0.0
        dot = sum(a * b for a, b in zip(emb_i, emb_j))
        norm_i = math.sqrt(sum(a * a for a in emb_i))
        norm_j = math.sqrt(sum(b * b for b in emb_j))
        if norm_i == 0 or norm_j == 0:
            return 0.0
        return dot / (norm_i * norm_j)

    @staticmethod
    def _average_embedding(embeddings: List[Optional[List[float]]]) -> Optional[List[float]]:
        """Compute mean embedding, skipping invalid entries."""
        valid = [e for e in embeddings if e and isinstance(e, list)]
        if not valid:
            return None
        dim = len(valid[0])
        if any(len(e) != dim for e in valid):
            return None
        totals = [0.0] * dim
        for emb in valid:
            for i, val in enumerate(emb):
                totals[i] += float(val)
        return [t / len(valid) for t in totals]

    @staticmethod
    def _message_similarity(title_a: str, title_b: str) -> float:
        """Word-level Jaccard similarity on the message portion of titles."""
        msg_a = title_a.split(":", 1)[1].strip().lower() if ":" in title_a else ""
        msg_b = title_b.split(":", 1)[1].strip().lower() if ":" in title_b else ""
        words_a = set(msg_a.split())
        words_b = set(msg_b.split())
        if not words_a or not words_b:
            return 0.0
        intersection = len(words_a & words_b)
        union = len(words_a | words_b)
        return intersection / union if union > 0 else 0.0

    def _update_lifecycle(
        self,
        new_groups: List[IssueGroup],
        existing_groups: List[IssueGroup],
    ) -> List[IssueGroup]:
        """Update lifecycle state based on comparison with existing groups."""
        existing_fps: Set[str] = set()
        resolved_fps: Set[str] = set()

        for g in existing_groups:
            for fp in g.all_fingerprints:
                if g.lifecycle == IssueLifecycle.RESOLVED:
                    resolved_fps.add(fp)
                else:
                    existing_fps.add(fp)

        for group in new_groups:
            group_fps = group.all_fingerprints
            if not group_fps:
                continue

            if group_fps & resolved_fps:
                group.lifecycle = IssueLifecycle.REGRESSED
            elif group_fps & existing_fps:
                group.lifecycle = IssueLifecycle.RECURRING
            else:
                group.lifecycle = IssueLifecycle.NEW

        # Mark existing groups not in new_groups as resolved (on copies to avoid mutation)
        new_fps = set()
        for g in new_groups:
            new_fps.update(g.all_fingerprints)

        updated_existing: List[IssueGroup] = []
        for g in existing_groups:
            if g.all_fingerprints and not (g.all_fingerprints & new_fps):
                if g.lifecycle != IssueLifecycle.RESOLVED:
                    g_copy = copy.copy(g)
                    g_copy.lifecycle = IssueLifecycle.RESOLVED
                    updated_existing.append(g_copy)

        return new_groups + updated_existing

    def suppress_noise(
        self,
        groups: List[IssueGroup],
        max_severity_to_suppress: int = 2,
        min_occurrences_to_suppress: int = 5,
    ) -> List[IssueGroup]:
        """Auto-suppress low-severity issues that repeat without user action."""
        return [
            g for g in groups
            if not (
                g.severity <= max_severity_to_suppress
                and g.occurrence_count >= min_occurrences_to_suppress
                and g.lifecycle == IssueLifecycle.RECURRING
            )
        ]
