# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# Licensed under the MIT License

"""
Unified Email Scorer — six-signal weighted classification.

Combines header intelligence, sender reputation, content analysis,
engagement history, user corrections, and contact data into a single
weighted score per category.

Each signal produces a (category, confidence) tuple. The scorer
combines them with configurable weights. The winning category is
the one with the highest weighted score.

Signals:
  S0: Headers     (0.15) — Precedence, X-Mailer, X-Campaign-Id, Return-Path
  S1: Sender      (0.20) — domain lists, dynamic reputation
  S2: Content     (0.15) — keyword matching (+ TF-IDF model in Sprint 4)
  S3: Engagement  (0.25) — opens, replies, trashes per sender
  S4: Corrections (0.20) — explicit user feedback (highest per-signal)
  S5: Contacts    (0.05) — address book, relationship tier
"""

import logging
from typing import Optional

logger = logging.getLogger(__name__)

# Signal weights — must sum to 1.0
WEIGHTS = {
    "header": 0.15,
    "sender": 0.20,
    "content": 0.15,
    "engagement": 0.25,
    "correction": 0.20,
    "contact": 0.05,
}

CATEGORIES = [
    "Work",           # Professional correspondence, colleagues, clients
    "Family",         # Family members, close personal
    "Personal",       # Friends, personal subscriptions, hobbies
    "Financial",      # Banks, payments, billing, credit cards, receipts
    "Dev/CI",         # GitHub, CI/CD, cloud platform alerts, API notices
    "News/Reading",   # Newsletters you actually read, Google Scholar, news
    "Job Search",     # Indeed, ZipRecruiter, job boards, career
    "Political",      # Political campaigns, advocacy, fundraising
    "Shopping/Deals", # Retail, sales, coupons, product marketing
    "Events",         # Meetups, RSVPs, invitations, conferences, tickets
    "Services",       # Account notices, password resets, service updates
    "Spam",           # Catch-all for unwanted, unsolicited, scam-like
    "Other",          # Doesn't fit any category
]
PRIORITIES = ["urgent", "action", "fyi"]


def score_email(
    sender_email: str,
    sender_name: str,
    subject: str,
    snippet: str = "",
    has_list_unsubscribe: bool = False,
    header_signals: Optional[dict] = None,
) -> dict:
    """Score an email using all 6 signals.

    Returns:
        {
            "category": "Promotional",
            "priority": "fyi",
            "confidence": 0.87,
            "signals": {
                "header": {"category": "Promotional", "confidence": 0.90, "reason": "Precedence: bulk"},
                "sender": {"category": "Promotional", "confidence": 0.85, "reason": "humblebundle.com in promo list"},
                "content": {"category": "Other", "confidence": 0.10, "reason": "No keywords matched"},
                "engagement": {"category": "Promotional", "confidence": 0.80, "reason": "0 opens in 12 received"},
                "correction": {"category": null, "confidence": 0, "reason": "No corrections for this sender"},
                "contact": {"category": null, "confidence": 0, "reason": "Not in contacts"},
            },
        }
    """
    sender_email = (sender_email or "").lower().strip()
    domain = sender_email.split("@")[-1] if "@" in sender_email else ""

    signals = {}
    category_votes = {}  # category → weighted score

    # ── S0: Header Intelligence ───────────────────────────────────────
    signals["header"] = _score_headers(header_signals, has_list_unsubscribe)

    # ── S1: Sender Identity (domain lists + learned) ──────────────────
    signals["sender"] = _score_sender(sender_email, sender_name, domain)

    # ── S2: Content / Keywords ────────────────────────────────────────
    signals["content"] = _score_content(sender_email, sender_name, subject, snippet, has_list_unsubscribe)

    # ── S3: Engagement ────────────────────────────────────────────────
    signals["engagement"] = _score_engagement(sender_email)

    # ── S4: User Corrections ──────────────────────────────────────────
    signals["correction"] = _score_corrections(sender_email, domain)

    # ── S5: Contacts ──────────────────────────────────────────────────
    signals["contact"] = _score_contacts(sender_email)

    # ── Combine weighted scores ───────────────────────────────────────
    for signal_name, signal in signals.items():
        cat = signal.get("category")
        conf = signal.get("confidence", 0)
        weight = WEIGHTS.get(signal_name, 0)
        if cat and conf > 0:
            category_votes[cat] = category_votes.get(cat, 0) + (conf * weight)

    # Pick winner
    if category_votes:
        best_cat = max(category_votes, key=category_votes.get)
        total_score = sum(category_votes.values())
        confidence = round(category_votes[best_cat] / max(total_score, 0.01), 2)
    else:
        best_cat = "Other"
        confidence = 0.10

    # Priority from content analysis
    priority = _determine_priority(subject, best_cat, sender_email)

    return {
        "category": best_cat,
        "priority": priority,
        "confidence": confidence,
        "signals": signals,
    }


# ── Signal Scorers ────────────────────────────────────────────────────────────


def _score_headers(header_signals: Optional[dict], has_list_unsubscribe: bool) -> dict:
    """S0: Score based on email headers."""
    if not header_signals and not has_list_unsubscribe:
        return {"category": None, "confidence": 0, "reason": "No header data"}

    hs = header_signals or {}
    precedence = (hs.get("precedence") or "").lower()
    x_mailer = (hs.get("x_mailer") or "").lower()
    x_campaign = hs.get("x_campaign_id") or ""
    return_path = (hs.get("return_path") or "").lower()

    if precedence in ("bulk", "list", "junk"):
        return {"category": "Shopping/Deals", "confidence": 0.90, "reason": f"Precedence: {precedence}"}

    _BULK_MAILERS = (
        "mailchimp", "sendgrid", "constant contact", "hubspot",
        "marketo", "pardot", "activecampaign", "brevo", "klaviyo",
        "mailgun", "sparkpost", "sendinblue", "campaign monitor",
        "drip", "convertkit", "aweber", "getresponse",
    )
    for m in _BULK_MAILERS:
        if m in x_mailer:
            return {"category": "Promotional", "confidence": 0.85, "reason": f"X-Mailer: {m}"}

    if x_campaign:
        return {"category": "Promotional", "confidence": 0.80, "reason": "X-Campaign-Id present"}

    if has_list_unsubscribe:
        # List-Unsubscribe just means it's a mailing list — could be newsletters,
        # job alerts, political, etc. Low confidence, don't assume Shopping.
        return {"category": "Shopping/Deals", "confidence": 0.30, "reason": "List-Unsubscribe header (mailing list)"}

    return {"category": None, "confidence": 0, "reason": "No bulk header signals"}


def _score_sender(sender_email: str, sender_name: str, domain: str) -> dict:
    """S1: Score based on sender domain lists with fine-grained categories."""
    # Fine-grained domain → category mapping
    _DOMAIN_CATEGORIES = {
        # Financial
        "paypal.com": "Financial", "venmo.com": "Financial",
        "capitalone.com": "Financial", "chase.com": "Financial",
        "wellsfargo.com": "Financial", "creditonebank.com": "Financial",
        "robinhood.com": "Financial", "experian.com": "Financial",
        # Dev/CI
        "github.com": "Dev/CI", "gitlab.com": "Dev/CI",
        "bitbucket.org": "Dev/CI", "vercel.com": "Dev/CI",
        "netlify.com": "Dev/CI", "sentry.io": "Dev/CI",
        "pypi.org": "Dev/CI", "replit.com": "Dev/CI",
        # Job Search
        "indeed.com": "Job Search", "ziprecruiter.com": "Job Search",
        "glassdoor.com": "Job Search", "ihirechefs.com": "Job Search",
        "culinaryagents.com": "Job Search",
        # News/Reading
        "substack.com": "News/Reading",
        "scholar.google.com": "News/Reading",
        # Shopping/Deals
        "poshmark.com": "Shopping/Deals", "etsy.com": "Shopping/Deals",
        "ebay.com": "Shopping/Deals", "gamestop.com": "Shopping/Deals",
        "samsung.com": "Shopping/Deals", "tiffany.com": "Shopping/Deals",
        "amazon.com": "Shopping/Deals", "bestwestern.com": "Shopping/Deals",
        "humblebundle.com": "Shopping/Deals", "gog.com": "Shopping/Deals",
        "squareenix.com": "Shopping/Deals", "square-enix.com": "Shopping/Deals",
        "elegoo.com": "Shopping/Deals", "walgreens.com": "Shopping/Deals",
        "ancestry.com": "Shopping/Deals", "expedia.com": "Shopping/Deals",
        "shef.com": "Shopping/Deals", "grubhub.com": "Shopping/Deals",
        "doordash.com": "Shopping/Deals",
        # Services
        "google.com": "Services", "microsoft.com": "Services",
        "protonmail.com": "Services", "proton.me": "Services",
        "godaddy.com": "Services", "anthropic.com": "Services",
        "apple.com": "Services",
        # Events
        "meetup.com": "Events", "eventbrite.com": "Events",
        # Entertainment (→ Shopping/Deals)
        "crunchyroll.com": "Shopping/Deals", "peacocktv.com": "Shopping/Deals",
        "hbomax.com": "Shopping/Deals", "netflix.com": "Shopping/Deals",
        "disneyplus.com": "Shopping/Deals", "disney.com": "Shopping/Deals",
        "marvel.com": "Shopping/Deals", "startrek.com": "Shopping/Deals",
        "atari.com": "Shopping/Deals",
        # Social (→ Services)
        "linkedin.com": "Services", "facebookmail.com": "Services",
        # Political
        "actblue.com": "Political", "winred.com": "Political",
        "everyaction.com": "Political", "actionnetwork.org": "Political",
        "mobilize.us": "Political", "nationbuilder.com": "Political",
        "endlessurgency.com": "Political",
        "debhaaland.com": "Political", "alexvindman.com": "Political",
        "jamestalarico.com": "Political", "gavinnewsom.com": "Political",
        "marypeltola.com": "Political", "markkelly.com": "Political",
        # News/Reading
        "brilliant.org": "News/Reading",
        "nativenewsonline.net": "News/Reading", "poz.com": "News/Reading",
        # More Shopping
        "drivethrurpg.com": "Shopping/Deals", "heroforge.com": "Shopping/Deals",
        "starbucks.com": "Shopping/Deals",
        # Events
        "cinemark.com": "Events",
    }

    # Check fine-grained mapping first
    if domain in _DOMAIN_CATEGORIES:
        cat = _DOMAIN_CATEGORIES[domain]
        return {"category": cat, "confidence": 0.85, "reason": f"{domain} → {cat}"}
    parts = domain.split(".")
    if len(parts) > 2:
        parent = ".".join(parts[-2:])
        if parent in _DOMAIN_CATEGORIES:
            cat = _DOMAIN_CATEGORIES[parent]
            return {"category": cat, "confidence": 0.85, "reason": f"{parent} → {cat}"}

    # Fall back to the existing broad lists
    try:
        from familiar.skills.triage.skill import (
            NOTIFICATION_DOMAINS,
            PROMOTIONAL_DOMAINS,
            _POLITICAL_FUNDRAISING_PATTERNS,
        )

        if domain in PROMOTIONAL_DOMAINS:
            return {"category": "Shopping/Deals", "confidence": 0.80, "reason": f"{domain} in promo list"}
        if len(parts) > 2:
            parent = ".".join(parts[-2:])
            if parent in PROMOTIONAL_DOMAINS:
                return {"category": "Shopping/Deals", "confidence": 0.80, "reason": f"{parent} in promo list"}

        if domain in NOTIFICATION_DOMAINS:
            return {"category": "Services", "confidence": 0.75, "reason": f"{domain} in notification list"}
        if len(parts) > 2:
            parent = ".".join(parts[-2:])
            if parent in NOTIFICATION_DOMAINS:
                return {"category": "Services", "confidence": 0.75, "reason": f"{parent} in notification list"}

        sender_lower = sender_name.lower()
        if any(pat in sender_lower for pat in _POLITICAL_FUNDRAISING_PATTERNS):
            return {"category": "Political", "confidence": 0.75, "reason": "Political fundraising pattern in sender name"}

    except ImportError:
        pass

    return {"category": None, "confidence": 0, "reason": "Unknown sender domain"}


def _score_content(sender_email: str, sender_name: str, subject: str, snippet: str, has_unsub: bool) -> dict:
    """S2: Score based on content — custom categories first, then TF-IDF + keywords."""
    # Check custom categories (user-defined keyword filters)
    try:
        from familiar.skills.triage.skill import _load_triage_data

        triage = _load_triage_data()
        custom_cats = triage.get("custom_categories", [])
        content_lower = f"{subject} {snippet} {sender_name}".lower()

        for cc in custom_cats:
            matches = [kw for kw in cc.get("keywords", []) if kw in content_lower]
            if matches:
                return {
                    "category": cc["name"],
                    "confidence": min(0.85, 0.50 + len(matches) * 0.15),
                    "reason": f"Custom category: matched {', '.join(matches[:3])}",
                }
    except Exception:
        pass

    # Try TF-IDF model (trained on user's own data)
    try:
        from familiar.skills.email.model import predict as tfidf_predict, get_model_info

        info = get_model_info()
        if info.get("trained"):
            category, confidence = tfidf_predict(subject, sender_email)
            if category != "Other" and confidence > 0.3:
                return {
                    "category": category,
                    "confidence": min(confidence, 0.75),  # Cap — model supplements, doesn't override
                    "reason": f"TF-IDF model → {category} ({confidence:.0%})",
                }
    except Exception:
        pass

    # Fallback to keyword matching
    try:
        from familiar.skills.triage.skill import _categorize_email

        category, confidence = _categorize_email(sender_email, sender_name, subject, snippet, has_unsub)
        if category != "Other":
            return {"category": category, "confidence": confidence, "reason": f"Keyword match → {category}"}
        return {"category": "Other", "confidence": 0.10, "reason": "No keywords matched"}
    except Exception:
        return {"category": "Other", "confidence": 0.10, "reason": "Content scorer unavailable"}


def _score_engagement(sender_email: str) -> dict:
    """S3: Score based on interaction history."""
    try:
        from familiar.skills.email.engagement import get_engagement

        eng = get_engagement(sender_email)
        if not eng:
            return {"category": None, "confidence": 0, "reason": "New sender — no history"}

        score = eng.get("engagement_score", 0)
        total = eng.get("total_received", 0)
        opens = eng.get("opened", 0)
        replies = eng.get("replied", 0)
        trashed = eng.get("trashed", 0)

        if total < 2:
            return {"category": None, "confidence": 0, "reason": f"Too few emails ({total}) for engagement signal"}

        # High engagement → likely important (Work/Personal)
        if score > 2.0:
            cat = "Work" if replies > 0 else "Personal"
            return {
                "category": cat,
                "confidence": min(0.90, score / 5.0),
                "reason": f"High engagement ({replies} replies, {opens} opens in {total} received)",
            }

        # Medium engagement → notifications worth seeing
        if score >= 0.5:
            return {
                "category": "Notifications",
                "confidence": 0.50,
                "reason": f"Medium engagement ({opens} opens in {total} received)",
            }

        # Low engagement + many received → promotional/ignorable
        if total >= 5 and score < 0.3:
            trash_rate = trashed / total if total > 0 else 0
            conf = min(0.85, 0.50 + trash_rate * 0.5)
            return {
                "category": "Promotional",
                "confidence": conf,
                "reason": f"Low engagement ({opens} opens, {trashed} trashed in {total} received)",
            }

        return {"category": None, "confidence": 0, "reason": f"Engagement score {score:.2f} — inconclusive"}

    except Exception:
        return {"category": None, "confidence": 0, "reason": "Engagement data unavailable"}


def _score_corrections(sender_email: str, domain: str) -> dict:
    """S4: Score based on user corrections (highest per-signal weight)."""
    try:
        from familiar.skills.triage.skill import _load_triage_data

        triage = _load_triage_data()

        # Exact sender match
        if sender_email in triage.get("sender_categories", {}):
            cat = triage["sender_categories"][sender_email]
            return {"category": cat, "confidence": 0.95, "reason": f"User corrected {sender_email} → {cat}"}

        # Domain match
        if domain in triage.get("domain_categories", {}):
            cat = triage["domain_categories"][domain]
            return {"category": cat, "confidence": 0.85, "reason": f"User corrected *@{domain} → {cat}"}

    except Exception:
        pass

    return {"category": None, "confidence": 0, "reason": "No corrections for this sender"}


def _score_contacts(sender_email: str) -> dict:
    """S5: Score based on address book presence + relationship tier."""
    try:
        from familiar.skills.triage.skill import _load_triage_data

        triage = _load_triage_data()
        vips = set(triage.get("vip_senders", []))

        if sender_email in vips:
            return {"category": "Work", "confidence": 0.70, "reason": "VIP sender"}

        domain = sender_email.split("@")[-1] if "@" in sender_email else ""
        if domain and domain in vips:
            return {"category": "Work", "confidence": 0.60, "reason": f"VIP domain: @{domain}"}

    except Exception:
        pass

    # Check work domain (user-configured)
    try:
        from familiar.skills.triage.skill import _load_triage_data as _lt

        td = _lt()
        work_domains = td.get("work_domains", [])
        family_emails = td.get("family_emails", [])

        domain = sender_email.split("@")[-1] if "@" in sender_email else ""
        if domain and domain in work_domains:
            return {"category": "Work", "confidence": 0.90, "reason": f"Work domain: @{domain}"}
        if sender_email in family_emails:
            return {"category": "Family", "confidence": 0.90, "reason": "Family contact"}
    except Exception:
        pass

    # Check contacts store with relationship tier
    try:
        from familiar.skills.contacts.skill import _load_data as _load_contacts, _normalize_email

        contacts = _load_contacts().get("contacts", [])
        for c in contacts:
            if _normalize_email(c.get("email", "")) == sender_email:
                tier = c.get("relationship_tier", "acquaintance")
                name = c.get("name", "")
                if tier == "close":
                    return {"category": "Family", "confidence": 0.80, "reason": f"Close contact: {name}"}
                elif tier == "professional":
                    return {"category": "Work", "confidence": 0.65, "reason": f"Professional contact: {name}"}
                else:
                    return {"category": "Personal", "confidence": 0.40, "reason": f"Acquaintance: {name}"}
    except Exception:
        pass

    return {"category": None, "confidence": 0, "reason": "Not in contacts"}


def _determine_priority(subject: str, category: str, sender_email: str) -> str:
    """Determine priority from subject + category + sender rules."""
    try:
        from familiar.skills.triage.skill import _get_priority
        return _get_priority(subject, category, sender_email)
    except Exception:
        pass

    subject_lower = subject.lower()
    if any(w in subject_lower for w in ("urgent", "asap", "immediate", "critical", "action required")):
        return "urgent"
    if any(w in subject_lower for w in ("failed", "error", "alert", "expiring", "overdue", "below zero")):
        return "action"
    if category in ("Work", "Personal", "Family"):
        return "action"
    if category in ("Financial", "Dev/CI"):
        return "action"
    if category in ("Events",):
        return "action"
    return "fyi"
