"""
spidercrypt.poisoning
~~~~~~~~~~~~~~~~~~~~~
ML dataset data-poisoning detection engine.
"""

import re
import json
import math
from pathlib import Path
from collections import Counter
from typing import Any, Dict, List

from .patterns import (
    BACKDOOR_TRIGGER_PATTERNS,
    SUSPICIOUS_KEYS,
    LABEL_FIELD_NAMES,
    INSECURE_CODE_PATTERNS,
    SECURE_CODE_PATTERNS,
)


# ── Internal helpers ──────────────────────────────────────────────────────────

def _flatten_values(obj, depth: int = 0, max_depth: int = 6) -> List[str]:
    """Recursively collect all string values from a JSON object."""
    if depth > max_depth:
        return []
    values: List[str] = []
    if isinstance(obj, dict):
        for v in obj.values():
            values += _flatten_values(v, depth + 1, max_depth)
    elif isinstance(obj, list):
        for item in obj:
            values += _flatten_values(item, depth + 1, max_depth)
    elif isinstance(obj, str):
        values.append(obj)
    return values


def _auto_detect_label_fields(records: list) -> Dict[str, list]:
    """
    Detect fields that look like ML labels (by name or low cardinality).

    Returns:
        ``{field_name: [all_values]}``
    """
    if not records or not isinstance(records[0], dict):
        return {}

    all_keys: set = set()
    for rec in records:
        if isinstance(rec, dict):
            all_keys.update(rec.keys())

    candidates: Dict[str, list] = {}
    for key in all_keys:
        values = [
            str(rec[key]) for rec in records
            if isinstance(rec, dict) and key in rec
            and isinstance(rec[key], (str, int, float, bool))
        ]
        if not values:
            continue
        distinct = set(values)
        if key.lower() in LABEL_FIELD_NAMES or (len(distinct) <= 10 and len(values) >= 10):
            candidates[key] = values
    return candidates


def _shannon_entropy(values: list) -> float:
    """Compute Shannon entropy of *values*."""
    if not values:
        return 0.0
    counts = Counter(values)
    total = len(values)
    return -sum((c / total) * math.log2(c / total) for c in counts.values())


def _detect_label_flip_duplicates(records: list, label_fields: set) -> Dict[str, int]:
    """
    Find entries with identical non-label content but conflicting labels
    (label-flip attack).

    Returns:
        ``{serialised_non_label_content: number_of_distinct_labels}``
    """
    seen: Dict[str, list] = {}
    for rec in records:
        if not isinstance(rec, dict):
            continue
        non_label = {k: v for k, v in rec.items() if k not in label_fields}
        key = json.dumps(non_label, sort_keys=True, ensure_ascii=False)
        label_val = json.dumps(
            {k: rec[k] for k in rec if k in label_fields}, sort_keys=True
        )
        seen.setdefault(key, []).append(label_val)

    return {k: len(set(v)) for k, v in seen.items() if len(set(v)) > 1}


def _check_code_label_consistency(records: list) -> Dict[str, Any]:
    """
    Cross-check ``code`` field content against vulnerability labels.

    Returns:
        ``{"insecure_but_safe_label": [...], "secure_but_vuln_label": [...]}``
    """
    SAFE_VALUES = {"0", "false", "safe", "no", "benign", "clean"}
    VULN_VALUES = {"1", "true", "vulnerable", "yes", "malicious", "unsafe"}
    VULN_LABEL_KEYS = {
        "vulnerable", "is_vulnerable", "malicious",
        "is_malicious", "label", "target", "flag",
    }

    inconsistencies: Dict[str, list] = {
        "insecure_but_safe_label": [],
        "secure_but_vuln_label":   [],
    }

    for rec in records:
        if not isinstance(rec, dict):
            continue
        code = rec.get("code", "") or ""
        if not isinstance(code, str) or not code.strip():
            continue

        label_val = None
        for k in VULN_LABEL_KEYS:
            if k in rec:
                label_val = str(rec[k]).lower()
                break
        if label_val is None:
            continue

        if label_val in SAFE_VALUES:
            for pattern, desc in INSECURE_CODE_PATTERNS:
                if re.search(pattern, code):
                    inconsistencies["insecure_but_safe_label"].append(
                        {"desc": desc, "code_snippet": code[:120]}
                    )
                    break
        elif label_val in VULN_VALUES:
            for pattern, desc in SECURE_CODE_PATTERNS:
                if re.search(pattern, code):
                    inconsistencies["secure_but_vuln_label"].append(
                        {"desc": desc, "code_snippet": code[:120]}
                    )
                    break

    return inconsistencies


# ── Public API ────────────────────────────────────────────────────────────────

def analyze(data: Any, source: str = "<input>") -> Dict[str, Any]:
    """
    Analyse a parsed JSON object (list or dict) for data-poisoning signals.

    Args:
        data:   Already-parsed JSON value (``list`` or ``dict``).
        source: Label used in the report (e.g. filename).

    Returns:
        A structured report dict with keys:

        - ``file`` – source label
        - ``poisoning_detected`` (bool)
        - ``risk_score`` (float 0–1)
        - ``warnings`` (list[str])
        - ``details`` (dict)
    """
    report: Dict[str, Any] = {
        "file":               source,
        "poisoning_detected": False,
        "risk_score":         0.0,
        "warnings":           [],
        "details":            {},
    }
    score_acc = 0.0

    records = data if isinstance(data, list) else [data]
    total   = len(records)
    report["details"]["total_records"] = total

    # 1. Backdoor triggers
    all_text  = _flatten_values(data)
    triggered = []
    for text in all_text:
        for pat in BACKDOOR_TRIGGER_PATTERNS:
            if re.search(pat, text):
                triggered.append(text[:120])
                break
    if triggered:
        ratio = len(triggered) / max(len(all_text), 1)
        report["warnings"].append(
            f"🎯 Backdoor triggers detected in {len(triggered)} value(s) "
            f"({ratio * 100:.1f}% of analysed text)."
        )
        report["details"]["triggered_samples"] = triggered[:5]
        score_acc += min(0.35, ratio * 4)

    # 2. Suspicious JSON keys
    def _find_suspicious_keys(obj, found=None):
        if found is None:
            found = set()
        if isinstance(obj, dict):
            for k in obj:
                if k.lower() in SUSPICIOUS_KEYS:
                    found.add(k)
                _find_suspicious_keys(obj[k], found)
        elif isinstance(obj, list):
            for item in obj:
                _find_suspicious_keys(item, found)
        return found

    bad_keys = _find_suspicious_keys(data)
    if bad_keys:
        report["warnings"].append(f"⚠️  Suspicious JSON keys: {', '.join(bad_keys)}")
        score_acc += 0.2

    # 3. Label distribution
    label_candidates  = _auto_detect_label_fields(records)
    all_label_details: Dict[str, Any] = {}

    for field, values in label_candidates.items():
        counts      = Counter(values)
        entropy     = _shannon_entropy(values)
        most_common, most_common_count = counts.most_common(1)[0]
        dominance   = most_common_count / len(values)

        all_label_details[field] = {
            "distribution": dict(counts.most_common(10)),
            "entropy":      round(entropy, 4),
            "dominance":    round(dominance, 4),
        }

        if dominance == 1.0:
            report["warnings"].append(
                f"🚨 Field '{field}': 100% '{most_common}' across {len(values)} records "
                f"→ total uniformity (suspicious single-class dataset)."
            )
            score_acc += 0.30
        elif dominance > 0.85:
            report["warnings"].append(
                f"📊 Field '{field}': dominant label '{most_common}' at "
                f"{dominance * 100:.1f}% → suspicious imbalance."
            )
            score_acc += 0.2

        if entropy < 0.5 and len(counts) > 1:
            report["warnings"].append(
                f"📉 Field '{field}': very low entropy ({entropy:.3f}) → suspicious uniformity."
            )
            score_acc += 0.1

    if all_label_details:
        report["details"]["label_fields"] = all_label_details

    # 4. Code / label consistency
    if any("code" in rec for rec in records if isinstance(rec, dict)):
        code_issues = _check_code_label_consistency(records)
        n_insecure  = len(code_issues["insecure_but_safe_label"])
        n_secure    = len(code_issues["secure_but_vuln_label"])

        if n_insecure > 0:
            ratio = n_insecure / total
            report["warnings"].append(
                f"🔓 {n_insecure} record(s) ({ratio * 100:.1f}%): INSECURE code "
                f"but label 'safe' → strong inconsistency (label-poisoning)."
            )
            report["details"]["insecure_code_samples"] = \
                code_issues["insecure_but_safe_label"][:3]
            score_acc += min(0.45, ratio * 3)

        if n_secure > 0:
            ratio = n_secure / total
            report["warnings"].append(
                f"🔒 {n_secure} record(s) ({ratio * 100:.1f}%): SECURE code "
                f"but label 'vulnerable' → inconsistency (reverse label-poisoning)."
            )
            report["details"]["secure_code_mislabeled"] = \
                code_issues["secure_but_vuln_label"][:3]
            score_acc += min(0.45, ratio * 3)

        insecure_hits = []
        for rec in records:
            code = rec.get("code", "") if isinstance(rec, dict) else ""
            if not isinstance(code, str):
                continue
            for pat, desc in INSECURE_CODE_PATTERNS:
                if re.search(pat, code):
                    insecure_hits.append(desc)
                    break
        insecure_density = len(insecure_hits) / max(total, 1)
        report["details"]["insecure_code_density"] = round(insecure_density, 4)

        if insecure_density > 0.05:
            report["warnings"].append(
                f"☠️  High density of insecure code: {len(insecure_hits)}/{total} "
                f"records ({insecure_density * 100:.1f}%) contain known dangerous patterns."
            )
            score_acc += min(0.35, insecure_density * 4)

    # 5. Label-flip attacks
    all_label_field_names = set(label_candidates.keys())
    conflicts = _detect_label_flip_duplicates(records, all_label_field_names)
    if conflicts:
        report["warnings"].append(
            f"🔀 {len(conflicts)} identical entry/entries with contradictory labels "
            f"(possible label-flip attack)."
        )
        report["details"]["conflicting_samples"] = list(conflicts.keys())[:3]
        score_acc += min(0.35, len(conflicts) / max(total, 1) * 10)

    # 6. Raw duplicates
    raw_hashes = [json.dumps(r, sort_keys=True) for r in records if isinstance(r, dict)]
    dup_count  = len(raw_hashes) - len(set(raw_hashes))
    if total > 10 and dup_count / total > 0.1:
        report["warnings"].append(
            f"♻️  {dup_count} exact duplicates ({dup_count / total * 100:.1f}%) "
            f"→ suspicious over-representation."
        )
        score_acc += min(0.2, dup_count / total)

    # Final score
    report["risk_score"]         = round(min(score_acc, 1.0), 3)
    report["poisoning_detected"] = report["risk_score"] >= 0.40

    return report


def analyze_file(filepath: str, threshold: float = 0.40) -> Dict[str, Any]:
    """
    Load a JSON file and run :func:`analyze` on it.

    Args:
        filepath:  Path to the ``.json`` file.
        threshold: Risk score above which ``poisoning_detected`` is set.

    Returns:
        Same report dict as :func:`analyze`, with ``poisoning_detected``
        adjusted for *threshold*.
    """
    try:
        raw  = Path(filepath).read_text(encoding="utf-8", errors="replace")
        data = json.loads(raw)
    except json.JSONDecodeError as e:
        return {
            "file":               filepath,
            "poisoning_detected": False,
            "risk_score":         0.1,
            "warnings":           [f"Invalid JSON: {e}"],
            "details":            {},
        }

    report = analyze(data, source=filepath)
    report["poisoning_detected"] = report["risk_score"] >= threshold
    return report