import json
import logging
import datetime as dt
import re
from typing import Optional, List, Dict, Any

from sqlalchemy import select

from ..db import AsyncSessionLocal
from ..services.salesforce_gateway import SalesforceGateway
from ..model_defs.c2a_models import C2AJob, C2ASuggestion
from ..model_defs.c2a_policy import C2APolicy
from ..intelligence import similarity as sim
from ..notifications.slack_blocks import job_card
from ..services.slack_publisher import fire_and_forget

log = logging.getLogger(__name__)


FREE_EMAILS = {
    "gmail.com",
    "googlemail.com",
    "yahoo.com",
    "yahoo.co.uk",
    "hotmail.com",
    "outlook.com",
    "live.com",
    "aol.com",
    "icloud.com",
}


def _norm_domain(dom: str) -> str:
    if not dom:
        return ""
    d = dom.strip().lower()
    if d.startswith("www."):
        d = d[4:]
    return d


def _is_b2c_domain(dom: str) -> bool:
    d = _norm_domain(dom)
    return d in FREE_EMAILS or any(
        x in d for x in ("gmail", "yahoo", "outlook", "hotmail", "aol", "icloud")
    )


async def _select_fields_contact(sf: SalesforceGateway) -> List[str]:
    desc = await sf.describe("Contact")
    avail = {
        str((f.get("name") or "")).lower(): (f.get("name") or "")
        for f in desc.get("fields", [])
    }
    base = [
        "Id",
        "FirstName",
        "LastName",
        "Email",
        "Company",
        "Department",
        "MailingCountry",
        "Phone",
        "AccountId",
        "CreatedDate",
    ]
    return [avail[x.lower()] for x in base if x.lower() in avail] or ["Id"]


async def _select_fields_account(sf: SalesforceGateway) -> List[str]:
    desc = await sf.describe("Account")
    avail = {
        str((f.get("name") or "")).lower(): (f.get("name") or "")
        for f in desc.get("fields", [])
    }
    base = ["Id", "Name", "Website", "Phone", "BillingCountry"]
    if "domain__c" in avail:
        base.append("Domain__c")
    return [avail[x.lower()] for x in base if x.lower() in avail] or ["Id"]


async def _get_policy(db, account_id: str) -> Optional[C2APolicy]:
    pol = (
        await db.execute(
            select(C2APolicy).where(C2APolicy.account_id == str(account_id)).limit(1)
        )
    ).scalar_one_or_none()
    return pol


def _website_domain(website: str) -> str:
    w = (website or "").strip().lower()
    if not w:
        return ""
    # crude parse
    if "://" in w:
        w = w.split("://", 1)[1]
    w = w.split("/", 1)[0]
    return _norm_domain(w)


def _domain_match_strength(contact_email_domain: str, account_website: str) -> float:
    cd = _norm_domain(contact_email_domain)
    aw = _website_domain(account_website)
    if not cd or not aw:
        return 0.0
    if cd == aw:
        return 1.0
    # Allow subdomain containment scoring
    if cd.endswith("." + aw) or aw.endswith("." + cd):
        return 0.85
    # last two labels compare
    cd2 = ".".join(cd.split(".")[-2:])
    aw2 = ".".join(aw.split(".")[-2:])
    return 0.7 if cd2 == aw2 else 0.0


def _name_similarity(a: str, b: str, multi: bool = False) -> float:
    a = (a or "").strip().lower()
    b = (b or "").strip().lower()
    if not a or not b:
        return 0.0
    try:
        if multi:
            ts = sim.token_set_ratio(a, b) / 100.0
            to = sim.token_sort_ratio(a, b) / 100.0
            pr = sim.partial_ratio(a, b) / 100.0
            return max(ts, to, pr)
        else:
            return sim.token_set_ratio(a, b) / 100.0
    except Exception:
        return 0.0


def _geo_match_score(c_country: Optional[str], a_country: Optional[str]) -> float:
    if not c_country or not a_country:
        return 0.0
    return (
        1.0
        if (c_country or "").strip().lower() == (a_country or "").strip().lower()
        else 0.0
    )


def _phone_hint(c_phone: Optional[str], a_phone: Optional[str]) -> float:
    c = "".join([ch for ch in (c_phone or "") if ch.isdigit()])
    a = "".join([ch for ch in (a_phone or "") if ch.isdigit()])
    if not c or not a:
        return 0.0
    return 1.0 if c[:3] and c[:3] == a[:3] else 0.0


def _weighted_score(signals: Dict[str, float], weights=None) -> float:
    weights = weights or {"domain": 0.55, "name": 0.30, "geo": 0.10, "phone": 0.05}
    return sum((signals.get(k, 0.0) or 0.0) * w for k, w in weights.items())


def _tier(score: float, gap: float, threshold: float, min_gap: float) -> str:
    if score is None:
        return None  # type: ignore[return-value]
    if score >= float(threshold) and gap >= float(min_gap):
        return "CERTAIN"
    if score >= float(threshold):
        return "LIKELY"
    if score >= float(threshold) - 0.05:
        return "POSSIBLE"
    return "REVIEW"


def _reg_version() -> str:
    try:
        import json as _json
        import hashlib as _hashlib
        from .domain_family import get_registry

        reg = get_registry()
        items = sorted(reg.alias_to_family.items())
        return _hashlib.sha1(
            _json.dumps(items, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
        ).hexdigest()[:12]
    except Exception:
        return ""


async def run_c2a_job(job_id: str):
    async with AsyncSessionLocal() as db:
        job = await db.get(C2AJob, job_id)
        if not job:
            return
        try:
            job.status = "running"
            job.started_at = dt.datetime.utcnow()
            job.updated_at = dt.datetime.utcnow()
            await db.commit()

            sf = await SalesforceGateway.for_tenant(job.account_id, db)
            params = {}
            try:
                params = json.loads(job.params_json or "{}")
            except Exception:
                params = {}
            # Load policy settings
            pol = await _get_policy(db, job.account_id)
            threshold = float(params.get("threshold", pol.threshold if pol else 0.7))
            min_gap = float(pol.min_score_gap) if pol else 0.05
            use_multi = bool(pol.use_multi_algo) if pol else True
            # Weights parsing (optional)
            weights = None
            try:
                if pol and pol.weights_json:
                    weights = json.loads(pol.weights_json or "{}") or None
            except Exception:
                weights = None
            b2c_skip = bool(params.get("b2c_skip", True))
            sample_limit = params.get("sample_limit")

            c_fields = await _select_fields_contact(sf)
            a_fields = await _select_fields_account(sf)

            # Filter scope: specific contacts -> only those; else null-Account contacts
            contact_ids = []
            try:
                contact_ids = list(dict.fromkeys((params.get("contact_ids") or [])))
            except Exception:
                contact_ids = []
            if contact_ids:
                safe_ids = [
                    i
                    for i in contact_ids
                    if isinstance(i, str) and len(i) in (15, 18) and i.startswith("003")
                ]
                if safe_ids:
                    ids_clause = ",".join(f"'{sid}'" for sid in safe_ids[:1000])
                    where = f" WHERE Id IN ({ids_clause})"
                else:
                    where = " WHERE AccountId = NULL"
            else:
                where = " WHERE AccountId = NULL"
            # Prefer excluding Person Accounts where present
            try:
                desc = await sf.describe("Contact")
                if any(
                    (f.get("name") == "IsPersonAccount") for f in desc.get("fields", [])
                ):
                    where += " AND IsPersonAccount = FALSE"
            except Exception:
                pass

            fields_sql = ", ".join(c_fields)
            limit_sql = f" LIMIT {int(sample_limit)}" if sample_limit else ""
            soql = f"SELECT {fields_sql} FROM Contact{where}{limit_sql}"

            # Use paginate_soql to safely stream all contacts without requiring a LIMIT
            # This avoids the SalesforceGateway LIMIT security block on non-COUNT queries
            # and processes records in batches.

            # Preload Accounts and build indices to avoid per-contact queries
            accounts: List[Dict[str, Any]] = []
            try:
                query_accounts = f"SELECT {', '.join(a_fields)} FROM Account LIMIT 1000"
                async for batch in sf.paginate_soql(query_accounts):
                    accounts.extend(batch or [])
            except Exception as e:
                log.warning(
                    f"Account preload failed, falling back to per-contact queries: {e}"
                )
                accounts = []

            id_to_account: Dict[str, Dict[str, Any]] = {}
            domain_index: Dict[str, List[Dict[str, Any]]] = {}
            name_index: Dict[str, set] = {}

            if accounts:
                for a in accounts:
                    aid = a.get("Id")
                    if not aid:
                        continue
                    id_to_account[aid] = a
                    # Domains from Website and Domain__c
                    doms = set()
                    doms.add(_website_domain(a.get("Website") or ""))
                    v = a.get("Domain__c") if "Domain__c" in a else None
                    if v:
                        doms.add(_norm_domain(v))
                    for d in list(doms):
                        if not d:
                            continue
                        domain_index.setdefault(d, []).append(a)

                    # Name tokens
                    name = (a.get("Name") or "").lower()
                    tokens = [t for t in re.findall(r"[a-z0-9]+", name) if len(t) >= 3]
                    for t in set(tokens):
                        name_index.setdefault(t, set()).add(aid)

            processed = 0
            created = 0
            skipped_b2c_count = 0
            conflicts_count = 0
            # Policy loaded earlier as `pol`

            async def candidate_accounts_for(
                contact: Dict[str, Any],
            ) -> List[Dict[str, Any]]:
                # Use in-memory indices if available; otherwise fallback to light queries
                email = (contact.get("Email") or "").lower()
                dom = email.split("@")[-1] if "@" in email else ""
                dom = _norm_domain(dom)

                candidates_map: Dict[str, Dict[str, Any]] = {}

                # Domain-based
                if dom and not (b2c_skip and _is_b2c_domain(dom)):
                    if domain_index:
                        for a in domain_index.get(dom, []) or []:
                            if a.get("Id"):
                                candidates_map[a["Id"]] = a
                    else:
                        # Fallback: small SOQL
                        q = f"SELECT {', '.join(a_fields)} FROM Account WHERE (Website LIKE '%{dom}%') LIMIT 200"
                        try:
                            a_res = await sf.soql(q)
                            for a in a_res.get("records", []) or []:
                                if a.get("Id"):
                                    candidates_map[a["Id"]] = a
                        except Exception:
                            pass

                # Name-based
                dept = contact.get("Department") or ""
                fullname = f"{contact.get('FirstName','')} {contact.get('LastName','')}".strip()
                company = contact.get("Company") or dept or fullname
                if company:
                    norm = company.lower()
                    tokens = [t for t in re.findall(r"[a-z0-9]+", norm) if len(t) >= 3]
                    if name_index and id_to_account:
                        idset = set()
                        for t in set(tokens):
                            idset |= name_index.get(t, set())
                        # Cap candidates to avoid explosion
                        for aid in list(idset)[:1000]:
                            a = id_to_account.get(aid)
                            if a:
                                candidates_map[aid] = a
                    else:
                        qn = company.replace("'", "\\'")
                        q = f"SELECT {', '.join(a_fields)} FROM Account WHERE Name LIKE '%{qn}%' LIMIT 200"
                        try:
                            a_res = await sf.soql(q)
                            for a in a_res.get("records", []) or []:
                                if a.get("Id"):
                                    candidates_map[a["Id"]] = a
                        except Exception:
                            pass

                return list(candidates_map.values())

            async def handle_page(page: dict):
                nonlocal processed, created, skipped_b2c_count, conflicts_count
                records = page.get("records") or []
                for c in records:
                    # B2C filter
                    em = (c.get("Email") or "").lower()
                    ed = em.split("@")[-1] if "@" in em else ""
                    if b2c_skip and _is_b2c_domain(ed):
                        processed += 1
                        skipped_b2c_count += 1
                        continue

                    cands = await candidate_accounts_for(c)
                    best = None
                    best_signals = None
                    fullname = f"{c.get('FirstName','')} {c.get('LastName','')}".strip()
                    scores = []
                    for a in cands:
                        signals = {
                            "domain": _domain_match_strength(
                                ed, a.get("Website") or ""
                            ),
                            "name": _name_similarity(
                                c.get("Department") or fullname,
                                a.get("Name") or "",
                                multi=use_multi,
                            ),
                            "geo": _geo_match_score(
                                c.get("MailingCountry"), a.get("BillingCountry")
                            ),
                            "phone": _phone_hint(c.get("Phone"), a.get("Phone")),
                        }
                        score = _weighted_score(signals, weights=weights)
                        scores.append(score)
                        if not best or score > best[0]:
                            best = (score, a)
                            best_signals = signals
                    # conflict detection: second-best within 5 points of best
                    if scores:
                        top = sorted(scores, reverse=True)
                        score_gap = (top[0] - top[1]) if len(top) >= 2 else 1.0
                        if len(top) >= 2 and score_gap <= 0.05:
                            conflicts_count += 1
                    else:
                        score_gap = 0.0

                    if best and best[0] >= float(threshold):
                        # Determine tier and reasons
                        t = _tier(
                            float(best[0]),
                            float(score_gap or 0.0),
                            float(threshold),
                            float(min_gap),
                        )
                        reasons = []
                        try:
                            for k, v in (best_signals or {}).items():
                                if (v or 0.0) >= 0.70:
                                    reasons.append(k)
                        except Exception:
                            reasons = []
                        # Determine autopilot eligibility tier (suggest-only)
                        tier_value = t
                        try:
                            import os

                            if os.getenv(
                                "AUTOPILOT_ELIGIBILITY_ENABLED", "false"
                            ).lower() in ("1", "true", "yes"):
                                from ..utils.tiering import classify_tier

                                evidence_strength = 0.0
                                try:
                                    evidence_strength = max(
                                        (best_signals or {}).values() or [0.0]
                                    )
                                except Exception:
                                    evidence_strength = 0.0
                                # Use policy threshold as suggest; auto_link approximated as suggest + min_gap (policy-driven)
                                tier_value = classify_tier(
                                    score=float(best[0]),
                                    suggest=float(threshold),
                                    auto_link=float(threshold) + float(min_gap or 0.0),
                                    evidence_strength=float(evidence_strength),
                                )
                        except Exception:
                            tier_value = t

                        s = C2ASuggestion(
                            policy_id=(pol.id if pol else ""),
                            contact_id=c.get("Id"),
                            account_id=best[1].get("Id"),
                            job_id=job.id,
                            status="pending",
                            reason_code="assign",
                            score=float(best[0]),
                            score_gap=float(score_gap),
                            explanation=json.dumps(best_signals or {}),
                            field_score_details=(best_signals or {}),
                            tier=tier_value,
                            reasons={"top": reasons} if reasons else {},
                            registry_version=_reg_version(),
                        )
                        db.add(s)
                        await db.flush()
                        created += 1

                    processed += 1
                job.processed = processed
                job.skipped_b2c = skipped_b2c_count
                job.conflicts = conflicts_count
                job.total = max(job.total or 0, processed)
                job.updated_at = dt.datetime.utcnow()
                await db.commit()

            async for batch in sf.paginate_soql(soql):
                await handle_page({"records": batch})

            job.status = "completed"
            job.total = processed
            job.skipped_b2c = skipped_b2c_count
            job.conflicts = conflicts_count
            job.applied = created
            job.updated_at = dt.datetime.utcnow()
            job.ended_at = dt.datetime.utcnow()
            await db.commit()

            # Slack notify (best-effort)
            try:
                from .notification_service import notify_c2a_job_done

                await notify_c2a_job_done(
                    str(job.account_id),
                    job.id,
                    job.status,
                    created,
                    skipped_b2c_count,
                    conflicts_count,
                )
            except Exception:
                pass

            # Channel post (best-effort)
            try:
                blocks = job_card(
                    "c2a",
                    job.id,
                    job.status,
                    applied=created,
                    skipped=(job.skipped_b2c or 0),
                    errors=(getattr(job, "error_count", 0) or 0),
                )
                fire_and_forget("c2a", str(job.account_id), blocks)
            except Exception:
                pass

        except Exception as e:
            log.error(f"C2A job {job_id} failed: {e}")
            job.status = "failed"
            job.updated_at = dt.datetime.utcnow()
            await db.commit()
            # Channel post (best-effort)
            try:
                blocks = job_card(
                    "c2a",
                    job.id,
                    "failed",
                    applied=0,
                    skipped=(getattr(job, "skipped_b2c", 0) or 0),
                    errors=(getattr(job, "error_count", 0) or 0),
                )
                fire_and_forget("c2a", str(job.account_id), blocks)
            except Exception:
                pass
