"""Service: signal_scorer — Weighted composite scoring per prospect with freshness decay.

Computes a 0.0-1.0 composite signal score for each prospect based on:
- Signal type weights (job_change=0.5, competitor=0.4, etc.)
- Signal confidence from classification
- Freshness decay (signals lose value over time)
- Best-signal-as-baseline with diminishing stacking from additional types

A single strong signal (e.g. competitor mention with high confidence) can
score high enough to trigger outreach on its own. Additional signal types
add supplementary value.

Scores are stored in the signal_accounts table for fast dashboard lookups.
"""

from __future__ import annotations

import logging
import time
from typing import Any

from ..constants import (
    COMPOUND_INTENT_LOOKBACK_DAYS,
    COMPOUND_INTENT_MIN_TYPES,
    COMPOUND_INTENT_PATTERNS,
    SIGNAL_COMMENTER_MATCH,
    SIGNAL_COMPETITOR_MENTION,
    SIGNAL_FUNDING_EVENT,
    SIGNAL_HIRING_SURGE,
    SIGNAL_JOB_CHANGE,
    SIGNAL_KEYWORD_MENTION,
    SIGNAL_NEWS_EVENT,
    SIGNAL_POST_ENGAGEMENT,
    SIGNAL_PROFILE_VIEW,
    SIGNAL_PROSPECT_POST,
    SIGNAL_STATUS_CLASSIFIED,
    SIGNAL_WEIGHT_COMMENTER_MATCH,
    SIGNAL_WEIGHT_COMPETITOR_MENTION,
    SIGNAL_WEIGHT_FUNDING_EVENT,
    SIGNAL_WEIGHT_HIRING_SURGE,
    SIGNAL_WEIGHT_JOB_CHANGE,
    SIGNAL_WEIGHT_KEYWORD_MENTION,
    SIGNAL_WEIGHT_NEWS_EVENT,
    SIGNAL_WEIGHT_POST_ENGAGEMENT,
    SIGNAL_WEIGHT_PROFILE_VIEW,
    SIGNAL_WEIGHT_PROSPECT_POST,
)

logger = logging.getLogger(__name__)

# Signal type → base weight mapping
SIGNAL_WEIGHTS: dict[str, float] = {
    SIGNAL_KEYWORD_MENTION: SIGNAL_WEIGHT_KEYWORD_MENTION,
    SIGNAL_PROSPECT_POST: SIGNAL_WEIGHT_PROSPECT_POST,
    SIGNAL_JOB_CHANGE: SIGNAL_WEIGHT_JOB_CHANGE,
    SIGNAL_COMPETITOR_MENTION: SIGNAL_WEIGHT_COMPETITOR_MENTION,
    SIGNAL_HIRING_SURGE: SIGNAL_WEIGHT_HIRING_SURGE,
    SIGNAL_FUNDING_EVENT: SIGNAL_WEIGHT_FUNDING_EVENT,
    SIGNAL_NEWS_EVENT: SIGNAL_WEIGHT_NEWS_EVENT,
    SIGNAL_PROFILE_VIEW: SIGNAL_WEIGHT_PROFILE_VIEW,
    SIGNAL_POST_ENGAGEMENT: SIGNAL_WEIGHT_POST_ENGAGEMENT,
    SIGNAL_COMMENTER_MATCH: SIGNAL_WEIGHT_COMMENTER_MATCH,
}

# Freshness decay thresholds (age_in_days → multiplier)
FRESHNESS_DECAY: list[tuple[int, float]] = [
    (1, 1.0),     # < 1 day: full value
    (3, 0.85),    # 1-3 days
    (7, 0.65),    # 3-7 days
    (14, 0.40),   # 7-14 days
    (30, 0.20),   # 14-30 days
]
# Beyond 30 days: 0.1 (nearly expired)
DECAY_FLOOR = 0.1



def compute_freshness_decay(detected_at: int, now: int | None = None) -> float:
    """Compute freshness decay multiplier for a signal.

    Args:
        detected_at: Unix timestamp when signal was detected.
        now: Current Unix timestamp (defaults to time.time()).

    Returns:
        Decay multiplier between DECAY_FLOOR and 1.0.
    """
    if now is None:
        now = int(time.time())

    age_days = max(0, (now - detected_at) / 86400)

    for threshold_days, multiplier in FRESHNESS_DECAY:
        if age_days <= threshold_days:
            return multiplier

    return DECAY_FLOOR


def compute_signal_score(signal: dict[str, Any], now: int | None = None) -> float:
    """Compute the weighted score for a single signal.

    Formula: weight * confidence * freshness_decay

    Args:
        signal: Signal dict from the DB.
        now: Current Unix timestamp.

    Returns:
        Score between 0.0 and 1.0.
    """
    signal_type = signal.get("signal_type", "")
    confidence = signal.get("confidence", 0.0) or 0.0
    detected_at = signal.get("detected_at", 0) or 0

    # Get base weight for signal type
    weight = SIGNAL_WEIGHTS.get(signal_type, 0.2)

    # Freshness decay
    decay = compute_freshness_decay(detected_at, now)

    # For signals with no confidence set, use a moderate default
    if not confidence:
        confidence = 0.5

    return weight * confidence * decay


def compute_prospect_signal_score(linkedin_id: str, now: int | None = None) -> dict[str, Any]:
    """Compute composite signal score for a prospect across all signal types.

    Formula:
    1. For each signal: score = weight * confidence * freshness_decay
    2. Best signal defines baseline, normalized so max single = ~0.80
    3. Additional signal types add diminishing value (50% of their score)

    Args:
        linkedin_id: The prospect's LinkedIn provider ID.
        now: Current Unix timestamp.

    Returns:
        Dict with composite_score, total_signals, signal_types, top_signal_type, details.
    """
    from ..db.signal_queries import list_signals

    if now is None:
        now = int(time.time())

    # Get all non-expired signals for this prospect
    signals = list_signals(linkedin_id=linkedin_id, limit=100)

    if not signals:
        return {
            "composite_score": 0.0,
            "total_signals": 0,
            "signal_types": [],
            "top_signal_type": "",
            "details": [],
        }

    # Compute individual scores
    signal_scores: list[dict[str, Any]] = []
    type_set: set[str] = set()
    type_max: dict[str, float] = {}

    for sig in signals:
        # Skip expired signals
        expires_at = sig.get("expires_at", 0) or 0
        if expires_at and expires_at < now:
            continue

        score = compute_signal_score(sig, now)
        sig_type = sig.get("signal_type", "")
        type_set.add(sig_type)

        # Track highest score per type
        if sig_type not in type_max or score > type_max[sig_type]:
            type_max[sig_type] = score

        signal_scores.append({
            "signal_id": sig.get("id", ""),
            "signal_type": sig_type,
            "score": score,
            "confidence": sig.get("confidence", 0),
            "detected_at": sig.get("detected_at", 0),
            "intent": sig.get("intent", ""),
        })

    if not signal_scores:
        return {
            "composite_score": 0.0,
            "total_signals": 0,
            "signal_types": [],
            "top_signal_type": "",
            "details": [],
        }

    # Composite score: best signal as baseline, additional types stack on top.
    # A single strong signal (e.g. competitor mention) should score high on its own.
    sorted_scores = sorted(type_max.values(), reverse=True)
    best_score = sorted_scores[0] if sorted_scores else 0.0

    # Additional signal types add diminishing value (supplementary, not duplicative)
    additional = sum(sorted_scores[1:]) * 0.5

    # Normalize so best possible single signal (job_change=0.50, conf=1.0, fresh)
    # maps to ~0.80, leaving room for stacking to push toward 1.0
    max_single = max(SIGNAL_WEIGHTS.values())  # 0.50
    normalized = min((best_score / max_single) * 0.80 + additional, 1.0) if max_single > 0 else 0.0

    # Find top signal type
    top_type = max(type_max, key=type_max.get) if type_max else ""

    return {
        "composite_score": round(normalized, 3),
        "total_signals": len(signal_scores),
        "signal_types": sorted(type_set),
        "top_signal_type": top_type,
        "details": sorted(signal_scores, key=lambda x: x["score"], reverse=True)[:10],
    }


def recompute_all_signal_accounts() -> str:
    """Recompute composite scores for all prospects in signal_accounts.

    Called periodically by the scheduler (e.g., every hour) or manually.

    Returns:
        Summary string of results.
    """
    from ..db.signal_queries import list_signal_accounts, upsert_signal_account

    accounts = list_signal_accounts(limit=500)
    if not accounts:
        return "No signal accounts to recompute."

    now = int(time.time())
    updated = 0

    for acct in accounts:
        linkedin_id = acct.get("linkedin_id", "")
        if not linkedin_id:
            continue

        result = compute_prospect_signal_score(linkedin_id, now)

        # Update signal_accounts table
        upsert_signal_account(
            linkedin_id=linkedin_id,
            prospect_name=acct.get("prospect_name", ""),
            company=acct.get("company", ""),
        )

        # Now update the composite score directly
        from ..db.signal_queries import _update_signal_account_score

        _update_signal_account_score(
            linkedin_id=linkedin_id,
            composite_score=result["composite_score"],
            total_signals=result["total_signals"],
            top_signal_type=result["top_signal_type"],
        )
        updated += 1

    summary = f"Recomputed scores for {updated} signal accounts"
    logger.info(summary)
    return summary


# ──────────────────────────────────────────────
# Compound Intent Detection
# ──────────────────────────────────────────────


def detect_compound_intent(linkedin_id: str, now: int | None = None) -> list[dict[str, Any]]:
    """Detect compound intent events from stacked signals for a single prospect.

    Scans all non-expired signals within the lookback window and matches
    against known compound patterns (e.g., job_change + hiring_surge →
    "new_leader_building"). Creates intent_events in the DB for new matches.

    Compound patterns (defined in constants.COMPOUND_INTENT_PATTERNS):
      - job_change + hiring_surge → "new_leader_building" (0.90)
      - competitor_mention + keyword_mention → "active_evaluation" (0.85)
      - prospect_post + commenter_match → "engaged_thought_leader" (0.70)
      - funding_event + hiring_surge → "growth_mode" (0.80)
      - job_change + keyword_mention → "new_role_exploring" (0.80)
      - job_change + competitor_mention → "new_role_evaluating" (0.85)
      - funding_event + keyword_mention → "funded_and_searching" (0.75)
      - competitor_mention + prospect_post → "vocal_evaluator" (0.80)

    Args:
        linkedin_id: The prospect's LinkedIn provider ID.
        now: Current Unix timestamp (defaults to time.time()).

    Returns:
        List of intent event dicts that were created (empty if no new events).
    """
    from ..db.signal_queries import (
        intent_event_exists,
        list_signals,
        save_intent_event,
    )

    if now is None:
        now = int(time.time())

    # Fetch signals within the lookback window
    cutoff = now - (COMPOUND_INTENT_LOOKBACK_DAYS * 86400)
    signals = list_signals(linkedin_id=linkedin_id, limit=200)

    # Filter to recent, non-expired signals
    recent_signals: list[dict[str, Any]] = []
    for sig in signals:
        detected_at = sig.get("detected_at", 0) or 0
        expires_at = sig.get("expires_at", 0) or 0
        if detected_at >= cutoff and (not expires_at or expires_at > now):
            recent_signals.append(sig)

    if not recent_signals:
        return []

    # Group signals by type
    by_type: dict[str, list[dict[str, Any]]] = {}
    for sig in recent_signals:
        sig_type = sig.get("signal_type", "")
        if sig_type:
            by_type.setdefault(sig_type, []).append(sig)

    # Need at least 2 distinct signal types for any compound event
    if len(by_type) < COMPOUND_INTENT_MIN_TYPES:
        return []

    # Check against known compound patterns
    prospect_types = set(by_type.keys())
    created_events: list[dict[str, Any]] = []

    for pattern_types, (event_type, score_boost) in COMPOUND_INTENT_PATTERNS.items():
        # Check if the prospect has all signal types in this pattern
        if not pattern_types.issubset(prospect_types):
            continue

        # Dedup: skip if this compound event was already created recently
        if intent_event_exists(linkedin_id, event_type):
            continue

        # Gather contributing signal IDs and types
        contributing_ids: list[str] = []
        contributing_types: list[str] = []
        company: str | None = None

        for sig_type in pattern_types:
            # Pick the highest-confidence signal of this type
            type_signals = by_type[sig_type]
            best_sig = max(
                type_signals,
                key=lambda s: (s.get("confidence", 0) or 0, s.get("detected_at", 0) or 0),
            )
            sig_id = best_sig.get("id", "")
            if sig_id:
                contributing_ids.append(sig_id)
            contributing_types.append(sig_type)

            # Extract company from any signal that has it
            if not company:
                meta = best_sig.get("metadata_json")
                if meta:
                    try:
                        import json
                        meta_dict = json.loads(meta)
                        company = meta_dict.get("company") or meta_dict.get("new_company")
                    except (ValueError, TypeError):
                        pass

        # Create the intent event
        event_id = save_intent_event(
            linkedin_id=linkedin_id,
            event_type=event_type,
            signal_ids=contributing_ids,
            signal_types=contributing_types,
            composite_score=score_boost,
            company=company,
        )

        event = {
            "id": event_id,
            "linkedin_id": linkedin_id,
            "event_type": event_type,
            "signal_ids": contributing_ids,
            "signal_types": contributing_types,
            "composite_score": score_boost,
            "company": company,
        }
        created_events.append(event)
        logger.info(
            "Compound intent detected: %s for %s (score=%.2f, signals=%s)",
            event_type, linkedin_id, score_boost, contributing_types,
        )

    return created_events


def detect_all_compound_intents() -> str:
    """Scan all signal accounts for compound intent events.

    Called periodically by the scheduler. Iterates through accounts
    with 2+ signal types and runs detect_compound_intent() on each.

    Returns:
        Summary string of results.
    """
    from ..db.signal_queries import list_signal_accounts

    accounts = list_signal_accounts(limit=500)
    if not accounts:
        return "No signal accounts to scan for compound intents."

    now = int(time.time())
    total_events = 0
    scanned = 0

    for acct in accounts:
        linkedin_id = acct.get("linkedin_id", "")
        if not linkedin_id:
            continue

        # Quick check: need at least 2 signals to form a compound
        total_signals = acct.get("total_signals", 0)
        if total_signals < COMPOUND_INTENT_MIN_TYPES:
            continue

        scanned += 1
        try:
            events = detect_compound_intent(linkedin_id, now)
            total_events += len(events)
        except Exception as e:
            logger.warning("Compound intent detection failed for %s: %s", linkedin_id, e)

    summary = (
        f"Scanned {scanned} accounts for compound intents, "
        f"created {total_events} new intent events"
    )
    logger.info(summary)
    return summary
