"""Conditional Abliteration with Category-Selective Projection Fields.

Standard abliteration is all-or-nothing: it removes ALL refusal, including
legitimate safety boundaries. CAST (Lee et al., ICLR 2025 Spotlight) showed
that condition vectors can selectively gate activation steering at inference
time, but CAST doesn't modify weights.

This module synthesizes CAST's conditional gating with abliteration's weight
surgery. For each harm category c, we learn a category-specific projection
operator P_c. The key algebraic structure: the family {P_c} forms a *sheaf*
over the category lattice — projectors for parent categories consistently
restrict to child categories.

Contributions:
  1. **Category-selective projectors**: Per-category projection operators
     that remove refusal only for matched categories
  2. **Condition vector extraction**: Learn category signatures in
     activation space that gate projector application
  3. **Sheaf consistency**: Prove hierarchical consistency — abliterating
     "violence" equals union of "weapons" + "assault" + "threats"
  4. **Selective abliteration**: Weight-level conditional surgery

References:
    - Lee et al. (ICLR 2025): CAST — Conditional Activation Steering
    - Wollschlager et al. (2025): Geometry of Concepts in LLMs (arXiv:2502.17420)
    - Yeo et al. (EMNLP 2025): Understanding Refusal with SAEs (Findings of EMNLP)
    - Cracken AI (2025): Domain-specific abliteration on Kimi K2
"""

from __future__ import annotations

import logging
import math
from dataclasses import dataclass

import torch

logger = logging.getLogger(__name__)


@dataclass
class CategoryProjector:
    """A category-specific projection operator for selective abliteration."""

    category: str                    # harm category name
    condition_vector: torch.Tensor   # (hidden_dim,) activation pattern for this category
    projection_direction: torch.Tensor  # (hidden_dim,) category-specific refusal direction
    selectivity: float               # how specifically this targets one category (0-1)
    activation_threshold: float      # cosine sim threshold for condition matching
    refusal_removal_rate: float      # estimated refusal removal for matched inputs
    collateral_damage: float         # estimated refusal removal for non-matched inputs


@dataclass
class ConditionalAbliterationResult:
    """Result of conditional abliteration analysis."""

    # Category projectors
    n_categories: int
    projectors: list[CategoryProjector]
    category_names: list[str]

    # Sheaf consistency
    sheaf_consistency_score: float     # 0-1, how well projectors compose hierarchically
    max_inconsistency: float          # worst case hierarchical inconsistency
    consistency_violations: list[str]  # descriptions of consistency violations

    # Selectivity metrics
    mean_selectivity: float           # average category selectivity
    min_selectivity: float            # worst case (least selective projector)
    cross_category_leakage: torch.Tensor  # (n_cat, n_cat) leakage matrix

    # Geometric structure
    projector_angles: torch.Tensor    # (n_cat, n_cat) angles between projector directions
    condition_angles: torch.Tensor    # (n_cat, n_cat) angles between condition vectors
    orthogonality_score: float        # how orthogonal the category subspaces are

    # Recommendation
    viable_categories: list[str]      # categories where selective abliteration is safe
    risky_categories: list[str]       # categories with high collateral damage


class ConditionalAbliterator:
    """Learn category-selective projection fields for conditional abliteration.

    Instead of removing all refusal indiscriminately, this module learns
    per-category projectors that can be selectively applied based on
    input content. Each projector has a condition vector (what activates it)
    and a projection direction (what it removes).
    """

    def __init__(
        self,
        selectivity_threshold: float = 0.7,
        condition_threshold: float = 0.3,
        min_samples_per_category: int = 5,
    ):
        """
        Args:
            selectivity_threshold: Minimum selectivity for a projector to
                be considered viable (below this, too much collateral).
            condition_threshold: Cosine similarity threshold for condition
                vector matching.
            min_samples_per_category: Minimum harmful samples per category
                to learn a reliable projector.
        """
        self.selectivity_threshold = selectivity_threshold
        self.condition_threshold = condition_threshold
        self.min_samples_per_category = min_samples_per_category

    def analyze(
        self,
        category_activations: dict[str, torch.Tensor],
        harmless_activations: torch.Tensor,
        global_refusal_direction: torch.Tensor | None = None,
    ) -> ConditionalAbliterationResult:
        """Learn category-selective projectors and analyze their geometry.

        Args:
            category_activations: {category_name: (n_samples, hidden_dim)}
                activations for each harm category.
            harmless_activations: (n_harmless, hidden_dim) activations on
                harmless prompts.
            global_refusal_direction: Optional pre-computed global refusal
                direction for comparison.

        Returns:
            ConditionalAbliterationResult with projectors and analysis.
        """
        categories = sorted(category_activations.keys())
        n_cat = len(categories)

        if n_cat == 0 or harmless_activations.shape[0] < 2:
            return self._empty_result()

        harmless_mean = harmless_activations.mean(dim=0)

        # Step 1: Extract per-category condition vectors and projectors
        projectors: list[CategoryProjector] = []
        valid_categories: list[str] = []
        cat_directions: list[torch.Tensor] = []
        cat_conditions: list[torch.Tensor] = []

        for cat in categories:
            cat_acts = category_activations[cat]
            if cat_acts.shape[0] < self.min_samples_per_category:
                logger.info(
                    "Category '%s' has too few samples (%d < %d), skipping",
                    cat, cat_acts.shape[0], self.min_samples_per_category,
                )
                continue

            # Condition vector: mean activation pattern specific to this category
            # (difference from harmless mean, normalized)
            cat_mean = cat_acts.mean(dim=0)
            condition = cat_mean - harmless_mean
            cond_norm = condition.norm()
            if cond_norm < 1e-8:
                continue
            condition = condition / cond_norm

            # Category-specific refusal direction: direction that maximally
            # separates this category from harmless, while being orthogonal
            # to other categories' directions
            proj_dir = self._extract_category_direction(
                cat_acts, harmless_activations, cat_directions
            )

            if proj_dir is None:
                continue

            # Measure selectivity: how much does this projector affect
            # other categories?
            selectivity, collateral = self._measure_selectivity(
                proj_dir, condition, category_activations, cat,
                harmless_activations
            )

            # Estimate refusal removal rate
            cat_proj_magnitudes = (cat_acts @ proj_dir).abs().mean().item()
            harmless_proj_magnitudes = (harmless_activations @ proj_dir).abs().mean().item()
            removal_rate = cat_proj_magnitudes / max(
                cat_proj_magnitudes + harmless_proj_magnitudes, 1e-10
            )

            projectors.append(CategoryProjector(
                category=cat,
                condition_vector=condition,
                projection_direction=proj_dir,
                selectivity=selectivity,
                activation_threshold=self.condition_threshold,
                refusal_removal_rate=removal_rate,
                collateral_damage=collateral,
            ))

            valid_categories.append(cat)
            cat_directions.append(proj_dir)
            cat_conditions.append(condition)

        n_valid = len(valid_categories)
        if n_valid == 0:
            return self._empty_result()

        # Step 2: Compute cross-category geometry
        dir_stack = torch.stack(cat_directions)  # (n_valid, hidden_dim)
        cond_stack = torch.stack(cat_conditions)

        # Projector angle matrix
        proj_angles = self._compute_angle_matrix(dir_stack)

        # Condition angle matrix
        cond_angles = self._compute_angle_matrix(cond_stack)

        # Cross-category leakage matrix
        leakage = self._compute_leakage_matrix(
            projectors, category_activations, valid_categories
        )

        # Orthogonality score: mean absolute cosine between projector directions
        if n_valid > 1:
            cos_matrix = dir_stack @ dir_stack.T
            mask = ~torch.eye(n_valid, dtype=torch.bool)
            ortho_score = 1.0 - cos_matrix.abs()[mask].mean().item()
        else:
            ortho_score = 1.0

        # Step 3: Sheaf consistency check
        consistency, max_incon, violations = self._check_sheaf_consistency(
            projectors, category_activations, harmless_activations
        )

        # Step 4: Classify categories
        viable = [
            p.category for p in projectors
            if p.selectivity >= self.selectivity_threshold
        ]
        risky = [
            p.category for p in projectors
            if p.selectivity < self.selectivity_threshold
        ]

        # Selectivity stats
        selectivities = [p.selectivity for p in projectors]
        mean_sel = sum(selectivities) / len(selectivities) if selectivities else 0.0
        min_sel = min(selectivities) if selectivities else 0.0

        return ConditionalAbliterationResult(
            n_categories=n_valid,
            projectors=projectors,
            category_names=valid_categories,
            sheaf_consistency_score=consistency,
            max_inconsistency=max_incon,
            consistency_violations=violations,
            mean_selectivity=mean_sel,
            min_selectivity=min_sel,
            cross_category_leakage=leakage,
            projector_angles=proj_angles,
            condition_angles=cond_angles,
            orthogonality_score=ortho_score,
            viable_categories=viable,
            risky_categories=risky,
        )

    def _extract_category_direction(
        self,
        category_acts: torch.Tensor,
        harmless_acts: torch.Tensor,
        existing_directions: list[torch.Tensor],
    ) -> torch.Tensor | None:
        """Extract category-specific refusal direction.

        Uses difference-of-means (category_mean - harmless_mean)
        and then orthogonalizes against previously extracted directions
        to ensure category independence.
        """
        cat_mean = category_acts.mean(dim=0)
        harmless_mean = harmless_acts.mean(dim=0)

        diff = cat_mean - harmless_mean
        diff_norm = diff.norm()
        if diff_norm < 1e-8:
            return None

        direction = diff / diff_norm

        # Orthogonalize against existing category directions
        for existing in existing_directions:
            proj = (direction @ existing) * existing
            direction = direction - proj
            d_norm = direction.norm()
            if d_norm < 1e-8:
                return None
            direction = direction / d_norm

        return direction

    def _measure_selectivity(
        self,
        proj_dir: torch.Tensor,
        condition: torch.Tensor,
        category_activations: dict[str, torch.Tensor],
        target_category: str,
        harmless_activations: torch.Tensor,
    ) -> tuple[float, float]:
        """Measure how selectively a projector targets its intended category.

        Selectivity = 1 - (collateral damage / intended removal)
        Collateral = how much refusal is removed from non-target categories
        """
        target_acts = category_activations[target_category]
        target_effect = (target_acts @ proj_dir).abs().mean().item()

        if target_effect < 1e-10:
            return 0.0, 0.0

        # Measure effect on non-target categories
        collateral_effects = []
        for cat, acts in category_activations.items():
            if cat == target_category:
                continue
            # Check if condition matches (would this projector fire?)
            cat_mean = acts.mean(dim=0)
            harmless_mean = harmless_activations.mean(dim=0)
            cat_condition = cat_mean - harmless_mean
            cond_norm = cat_condition.norm()
            if cond_norm > 1e-8:
                cat_condition = cat_condition / cond_norm
                cos_sim = (cat_condition @ condition).abs().item()
                if cos_sim > self.condition_threshold:
                    # This category would trigger the projector
                    effect = (acts @ proj_dir).abs().mean().item()
                    collateral_effects.append(effect)

        total_collateral = sum(collateral_effects) if collateral_effects else 0.0
        mean_collateral = (
            total_collateral / len(collateral_effects)
            if collateral_effects
            else 0.0
        )

        selectivity = max(0.0, 1.0 - mean_collateral / max(target_effect, 1e-10))
        collateral_ratio = mean_collateral / max(target_effect, 1e-10)

        return selectivity, collateral_ratio

    def _compute_angle_matrix(self, vectors: torch.Tensor) -> torch.Tensor:
        """Compute pairwise angle matrix between vectors."""
        norms = vectors.norm(dim=-1, keepdim=True)
        safe_norms = torch.clamp(norms, min=1e-8)
        normalized = vectors / safe_norms
        cos_matrix = normalized @ normalized.T
        cos_matrix = torch.clamp(cos_matrix, -1.0, 1.0)
        angles = torch.acos(cos_matrix.abs()) * (180.0 / math.pi)
        return angles

    def _compute_leakage_matrix(
        self,
        projectors: list[CategoryProjector],
        category_activations: dict[str, torch.Tensor],
        valid_categories: list[str],
    ) -> torch.Tensor:
        """Compute cross-category leakage matrix.

        Entry (i,j) = how much projector i affects category j's refusal.
        Diagonal should be high (intended effect), off-diagonal low (leakage).
        """
        n = len(valid_categories)
        leakage = torch.zeros(n, n)

        for i, proj in enumerate(projectors):
            for j, cat in enumerate(valid_categories):
                if cat not in category_activations:
                    continue
                acts = category_activations[cat]
                effect = (acts @ proj.projection_direction).abs().mean().item()
                leakage[i, j] = effect

        # Normalize rows by diagonal
        diag = leakage.diag().clone()
        for i in range(n):
            if diag[i] > 1e-10:
                leakage[i] = leakage[i] / diag[i]

        return leakage

    def _check_sheaf_consistency(
        self,
        projectors: list[CategoryProjector],
        category_activations: dict[str, torch.Tensor],
        harmless_activations: torch.Tensor,
    ) -> tuple[float, float, list[str]]:
        """Check sheaf consistency of category projectors.

        The sheaf property requires that for parent category P containing
        child categories C1, C2, ..., the projector for P should be
        consistent with the union of child projectors:
            P_parent ≈ P_c1 + P_c2 + ... (in the projection space)

        Since we don't have explicit category hierarchy, we check pairwise
        consistency: projecting with P_a then P_b should be similar to
        projecting with P_a+b (combined direction).
        """
        violations: list[str] = []
        consistencies: list[float] = []

        n = len(projectors)
        if n < 2:
            return 1.0, 0.0, []

        for i in range(n):
            for j in range(i + 1, n):
                pi = projectors[i].projection_direction
                pj = projectors[j].projection_direction

                # Combined direction (unnormalized sum then normalize)
                combined = pi + pj
                c_norm = combined.norm()
                if c_norm < 1e-8:
                    continue
                combined = combined / c_norm

                # Sequential projection should approximate combined projection
                # on the combined category data
                cat_i = projectors[i].category
                cat_j = projectors[j].category

                acts_i = category_activations.get(cat_i)
                acts_j = category_activations.get(cat_j)
                if acts_i is None or acts_j is None:
                    continue

                combined_acts = torch.cat([acts_i, acts_j], dim=0)

                # Sequential removal
                seq_residual = combined_acts.clone()
                seq_residual = seq_residual - (seq_residual @ pi).unsqueeze(-1) * pi
                seq_residual = seq_residual - (seq_residual @ pj).unsqueeze(-1) * pj

                # Combined removal
                comb_residual = combined_acts - (combined_acts @ combined).unsqueeze(-1) * combined

                # Consistency = cosine similarity of residual patterns
                if seq_residual.norm() > 1e-8 and comb_residual.norm() > 1e-8:
                    # Compare mean residuals
                    seq_mean = seq_residual.mean(dim=0)
                    comb_mean = comb_residual.mean(dim=0)
                    consistency = torch.nn.functional.cosine_similarity(
                        seq_mean.unsqueeze(0), comb_mean.unsqueeze(0)
                    ).item()
                    consistencies.append(consistency)

                    if consistency < 0.7:
                        violations.append(
                            f"{cat_i} + {cat_j}: consistency = {consistency:.3f}"
                        )

        if not consistencies:
            return 1.0, 0.0, []

        mean_consistency = sum(consistencies) / len(consistencies)
        max_inconsistency = 1.0 - min(consistencies)

        return mean_consistency, max_inconsistency, violations

    def _empty_result(self) -> ConditionalAbliterationResult:
        return ConditionalAbliterationResult(
            n_categories=0,
            projectors=[],
            category_names=[],
            sheaf_consistency_score=1.0,
            max_inconsistency=0.0,
            consistency_violations=[],
            mean_selectivity=0.0,
            min_selectivity=0.0,
            cross_category_leakage=torch.zeros(1, 1),
            projector_angles=torch.zeros(1, 1),
            condition_angles=torch.zeros(1, 1),
            orthogonality_score=0.0,
            viable_categories=[],
            risky_categories=[],
        )
