"""Community contribution system for crowdsourced paper data.

Enables users to contribute anonymized experiment results to the shared
paper dataset. Unlike telemetry (which is fire-and-forget to a remote
endpoint), contributions are saved as local JSON files that can be
submitted via pull request to the community results repository.

Usage:
    from obliteratus.community import save_contribution

    # After running a pipeline:
    path = save_contribution(
        pipeline,
        model_name="meta-llama/Llama-2-7b-chat-hf",  # public model ID
        notes="Ran on A100 with default prompts",
    )
    # Generates: community_results/llama2-7b_advanced_20260227_143052.json
"""

from __future__ import annotations

import hashlib
import json
import logging
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from obliteratus.telemetry import (
    _direction_stats,
    _extract_excise_details,
    _extract_prompt_counts,
    _extract_stage_durations,
    _get_peak_vram,
    _metric_value,
    _safe_float,
    _sanitize_public_text,
    _sanitize_public_value,
    build_report,
)

logger = logging.getLogger(__name__)

# Schema version for community contributions (extends telemetry schema v2)
CONTRIBUTION_SCHEMA_VERSION = 1

# Default output directory for contributions
DEFAULT_CONTRIB_DIR = "community_results"


def _model_short_name(model_name: str) -> str:
    """Extract a filesystem-safe short name from a HuggingFace model ID."""
    # "meta-llama/Llama-2-7b-chat-hf" -> "llama-2-7b-chat-hf"
    name = model_name.split("/")[-1].lower()
    name = re.sub(r"[^a-z0-9\-]", "-", name)
    name = re.sub(r"-+", "-", name).strip("-")
    return name[:60]  # cap length


def _config_fingerprint(config: dict[str, Any]) -> str:
    """Deterministic short hash of the method configuration."""
    canonical = json.dumps(_sanitize_public_value(config), sort_keys=True, allow_nan=False)
    return hashlib.sha256(canonical.encode()).hexdigest()[:8]


def validate_contribution(data: Any) -> bool:
    """Return whether a contribution matches the supported public schema."""
    if not isinstance(data, dict):
        return False
    if data.get("contribution_schema_version") != CONTRIBUTION_SCHEMA_VERSION:
        return False
    telemetry = data.get("telemetry")
    return (
        isinstance(data.get("model_name"), str)
        and isinstance(data.get("timestamp"), str)
        and isinstance(telemetry, dict)
        and telemetry.get("schema_version") == 2
        and isinstance(telemetry.get("quality_metrics", {}), dict)
    )


def save_contribution(
    pipeline,
    *,
    model_name: str,
    notes: str = "",
    output_dir: str | Path = DEFAULT_CONTRIB_DIR,
    informed_report=None,
) -> Path:
    """Save a contribution record from a completed pipeline run.

    Unlike telemetry, this:
    - Includes the public model name (for aggregation by model)
    - Saves locally (not sent remotely)
    - Uses a human-readable filename
    - Includes a config fingerprint for deduplication
    - Is always explicit (no silent opt-in)

    Args:
        pipeline: A completed AbliterationPipeline instance.
        model_name: HuggingFace model ID (e.g., "meta-llama/Llama-2-7b-chat-hf").
        notes: Optional free-text notes about the run.
        output_dir: Directory to save contribution files.
        informed_report: Optional InformedPipelineReport for informed pipeline runs.

    Returns:
        Path to the saved contribution JSON file.
    """
    output_dir = Path(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    # Build the base telemetry report (reuse existing schema)
    summary = pipeline.handle.summary()

    config_keys = [
        "n_directions", "norm_preserve", "regularization",
        "refinement_passes", "project_biases", "use_chat_template",
        "use_whitened_svd", "true_iterative_refinement",
        "use_jailbreak_contrast", "layer_adaptive_strength",
        "attention_head_surgery", "safety_neuron_masking",
        "per_expert_directions", "use_sae_features", "invert_refusal",
        "project_embeddings", "embed_regularization",
        "activation_steering", "steering_strength",
        "expert_transplant", "transplant_blend",
        "reflection_strength",
    ]
    method_config = {}
    for key in config_keys:
        val = getattr(pipeline, key, None)
        if val is not None:
            method_config[key] = val

    # Extract analysis insights if informed report is available
    analysis_insights = None
    informed_extras = None
    if informed_report is not None:
        try:
            from obliteratus.telemetry import _extract_analysis_insights
            analysis_insights = _extract_analysis_insights(informed_report)
            informed_extras = {}
            if hasattr(informed_report, "ouroboros_passes"):
                informed_extras["ouroboros_passes"] = informed_report.ouroboros_passes
            if hasattr(informed_report, "final_refusal_rate"):
                informed_extras["final_refusal_rate"] = _safe_float(
                    informed_report.final_refusal_rate
                )
        except Exception:
            logger.debug("Failed to extract analysis insights from informed report", exc_info=True)

    base_report = build_report(
        architecture=summary.get("architecture", "unknown"),
        num_layers=summary.get("num_layers", 0),
        num_heads=summary.get("num_heads", 0),
        hidden_size=summary.get("hidden_size", 0),
        total_params=summary.get("total_params", 0),
        method=pipeline.method,
        method_config=method_config,
        quality_metrics=pipeline._quality_metrics,
        stage_durations=_extract_stage_durations(pipeline),
        strong_layers=pipeline._strong_layers,
        direction_stats=_direction_stats(pipeline),
        excise_details=_extract_excise_details(pipeline),
        prompt_counts=_extract_prompt_counts(pipeline),
        gpu_memory=_get_peak_vram(),
        analysis_insights=analysis_insights,
        informed_extras=informed_extras,
    )

    # Wrap in community contribution envelope
    timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
    contribution = {
        "contribution_schema_version": CONTRIBUTION_SCHEMA_VERSION,
        "timestamp": timestamp,
        "model_name": _sanitize_public_text(model_name, 120),
        "config_fingerprint": _config_fingerprint(method_config),
        "notes": _sanitize_public_text(notes, 240),
        "telemetry": base_report,
    }

    # Generate filename
    short_name = _model_short_name(model_name)
    method = _model_short_name(str(pipeline.method)) or "unknown"
    ts_short = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
    filename = f"{short_name}_{method}_{ts_short}.json"
    filepath = output_dir / filename

    filepath.write_text(
        json.dumps(contribution, indent=2, sort_keys=True, allow_nan=False) + "\n",
        encoding="utf-8",
    )
    logger.info("Community contribution saved: %s", filepath)
    return filepath


def load_contributions(
    contrib_dir: str | Path = DEFAULT_CONTRIB_DIR,
) -> list[dict[str, Any]]:
    """Load all contribution records from a directory.

    Args:
        contrib_dir: Directory containing contribution JSON files.

    Returns:
        List of parsed contribution records, sorted by timestamp.
    """
    contrib_dir = Path(contrib_dir)
    if not contrib_dir.exists():
        return []

    records = []
    for path in sorted(contrib_dir.glob("*.json")):
        try:
            data = json.loads(path.read_text())
            if validate_contribution(data):
                data["_source_file"] = path.name
                records.append(data)
        except (json.JSONDecodeError, OSError) as e:
            logger.warning("Skipping invalid contribution file %s: %s", path, e)

    records.sort(key=lambda r: r.get("timestamp", ""))
    return records


def aggregate_results(
    records: list[dict[str, Any]],
) -> dict[str, dict[str, Any]]:
    """Aggregate contribution records into per-model, per-method summaries.

    Groups results by (model_name, method) and computes summary statistics
    for key metrics (refusal_rate, perplexity, coherence).

    Returns:
        Nested dict: {model_name: {method: {metric: {mean, std, n, values}}}}
    """
    import statistics

    groups: dict[tuple[str, str], list[dict]] = {}

    for record in records:
        model = _sanitize_public_text(record.get("model_name", "unknown"), 120)
        telemetry = record.get("telemetry", {})
        method = _sanitize_public_text(telemetry.get("method", "unknown"), 80)
        metrics = telemetry.get("quality_metrics", {})

        key = (model, method)
        if key not in groups:
            groups[key] = []
        groups[key].append(metrics)

    results: dict[str, dict[str, Any]] = {}
    for (model, method), metric_list in groups.items():
        if model not in results:
            results[model] = {}

        summary: dict[str, Any] = {"n_runs": len(metric_list)}

        for metric_name in ["refusal_rate", "perplexity", "coherence"]:
            values = [
                value
                for metrics in metric_list
                if (value := _metric_value(metric_name, metrics.get(metric_name))) is not None
            ]
            if values:
                summary[metric_name] = {
                    "status": "measured",
                    "mean": round(statistics.mean(values), 4),
                    "std": round(statistics.stdev(values), 4) if len(values) > 1 else 0.0,
                    "n": len(values),
                    "min": round(min(values), 4),
                    "max": round(max(values), 4),
                    "unavailable": len(metric_list) - len(values),
                }
            else:
                summary[metric_name] = {
                    "status": "unavailable",
                    "mean": None,
                    "std": None,
                    "n": 0,
                    "min": None,
                    "max": None,
                    "unavailable": len(metric_list),
                }

        results[model][method] = summary

    return results


def generate_latex_table(
    aggregated: dict[str, dict[str, Any]],
    methods: list[str] | None = None,
    metric: str = "refusal_rate",
) -> str:
    """Generate a LaTeX table from aggregated community results.

    Args:
        aggregated: Output of aggregate_results().
        methods: Methods to include (default: all found).
        metric: Which metric to display (default: refusal_rate).

    Returns:
        LaTeX table source string.
    """
    if methods is None:
        all_methods: set[str] = set()
        for model_data in aggregated.values():
            all_methods.update(model_data.keys())
        methods = sorted(all_methods)

    def _escape(value: str) -> str:
        translations = {
            "\\": r"\textbackslash{}", "&": r"\&", "%": r"\%", "$": r"\$",
            "#": r"\#", "_": r"\_", "{": r"\{", "}": r"\}",
        }
        return "".join(translations.get(char, char) for char in value)

    # Build header
    method_cols = " & ".join(f"\\textbf{{{_escape(m)}}}" for m in methods)
    header = f"\\textbf{{Model}} & {method_cols} \\\\"

    lines = [
        "\\begin{tabular}{@{}l" + "c" * len(methods) + "@{}}",
        "\\toprule",
        header,
        "\\midrule",
    ]

    for model in sorted(aggregated.keys()):
        model_data = aggregated[model]
        short = model.split("/")[-1] if "/" in model else model

        cells = []
        for method in methods:
            if (
                method in model_data
                and metric in model_data[method]
                and model_data[method][metric].get("status", "measured") == "measured"
            ):
                stats = model_data[method][metric]
                mean = stats["mean"]
                n = stats["n"]
                if stats["std"] > 0 and n > 1:
                    cells.append(f"{mean:.1f}$\\pm${stats['std']:.1f} ({n})")
                else:
                    cells.append(f"{mean:.1f} ({n})")
            else:
                cells.append("---")

        row = f"{_escape(short)} & " + " & ".join(cells) + " \\\\"
        lines.append(row)

    lines.extend(["\\bottomrule", "\\end{tabular}"])
    return "\n".join(lines)
