import asyncio
import json
import logging
import os
import datetime as dt
from typing import List, Any, Optional

from sqlalchemy import select, update, func

from ..db import AsyncSessionLocal
from ..model_defs.l2a_models import L2ABulkJob, L2APolicy, L2ASuggestion
from ..services.salesforce_gateway import SalesforceGateway
from ..services.reporting_emitter import EventEmitter
from ..services.notification_service import notify_job_done
from ..notifications.slack_blocks import job_card
from ..services.slack_publisher import fire_and_forget
from ..model_defs.reporting_models import ChangeSet as ORMChangeSet

logger = logging.getLogger(__name__)


BATCH = 200


def _eta_seconds(
    processed: int, applied: int, start_dt: Optional[dt.datetime], total: int
) -> int:
    started = start_dt or dt.datetime.utcnow()
    elapsed = (dt.datetime.utcnow() - started).total_seconds() or 1.0
    rate = (processed / elapsed) if processed else 0.0
    remain = max(0, int(total or 0) - int(processed or 0))
    return int(remain / (rate or 1.0))


async def _get_field_types(sf, sobject: str) -> dict:
    desc = await sf.describe(sobject)
    return {f.get("name"): f.get("type") for f in desc.get("fields", [])}


def _escape_like(value: str) -> str:
    s = str(value or "")
    s = s.replace("%", "\\%").replace("_", "\\_")
    return s.replace("'", "\\'")


def _escape_literal(value: Any) -> str:
    return str(value or "").replace("'", "\\'")


def _build_where(filters: List[dict], field_types: dict) -> List[str]:
    parts: List[str] = []
    for f in filters or []:
        name = f.get("field")
        op = str(f.get("op") or "").lower()
        val = f.get("value")
        if not name or name not in field_types:
            continue
        ftype = (field_types.get(name) or "").lower()

        if op == "equals":
            parts.append(f"{name} = '{_escape_literal(val)}'")
        elif op == "not_equals":
            parts.append(f"{name} != '{_escape_literal(val)}'")
        elif op == "contains" and ftype in {"string", "email", "url", "phone"}:
            parts.append(f"{name} LIKE '%{_escape_like(val)}%'")
        elif op == "starts_with" and ftype in {"string", "email", "url", "phone"}:
            parts.append(f"{name} LIKE '{_escape_like(val)}%'")
        elif op == "is_empty":
            if ftype in {
                "string",
                "email",
                "url",
                "phone",
                "picklist",
                "multipicklist",
            }:
                parts.append(f"({name} = NULL OR {name} = '')")
            else:
                parts.append(f"({name} = NULL)")
        elif op == "in" and isinstance(val, list):
            safe_vals = [f"'{_escape_literal(v)}'" for v in val]
            if safe_vals:
                parts.append(f"{name} IN ({', '.join(safe_vals)})")
        elif op in {"before", "after", "on"} and ftype in {"date", "datetime"}:
            comp = {"before": "<", "after": ">", "on": "="}[op]
            parts.append(f"{name} {comp} '{_escape_literal(val)}'")
        elif op == "last_n_days" and ftype in {"date", "datetime"}:
            try:
                n = int(val)
                parts.append(f"{name} = LAST_N_DAYS:{n}")
            except Exception:
                continue
        else:
            parts.append(f"{name} = '{_escape_literal(val)}'")
    return parts


async def _select_fields_lead(sf, policy_writeback: Optional[str]) -> List[str]:
    desc = await sf.describe("Lead")
    avail = {
        (f.get("name") or "").lower(): (f.get("name") or "")
        for f in desc.get("fields", [])
    }
    base = ["Id"]
    if policy_writeback:
        base.append(policy_writeback)
    return [avail[x.lower()] for x in base if x.lower() in avail] or ["Id"]


async def _lead_context(
    sf: SalesforceGateway, lid: str, page_map: Optional[dict] = None
):
    """Capture helpful context for error lines with minimal overhead."""
    try:
        if page_map and lid in page_map:
            ctx = page_map.get(lid) or {}
            return ctx.get("Company"), ctx.get("Email")
    except Exception:
        pass
    try:
        rec = await sf.get_record("Lead", lid, fields=["Company", "Email"])
        return rec.get("Company"), rec.get("Email")
    except Exception:
        return None, None


async def run_bulk_link_job(job_id: str):
    """Background worker to apply L2A links across the query.

    Applies updates directly with audit events. Keeps job progress updated.
    """
    async with AsyncSessionLocal() as db:
        job = await db.get(L2ABulkJob, job_id)
        if not job or job.status not in ("running",):
            return

        try:
            job.started_at = job.started_at or dt.datetime.utcnow()
            job.updated_at = dt.datetime.utcnow()
            await db.commit()

            # Get gateway and policy
            sf = await SalesforceGateway.for_tenant(job.account_id, db)
            pol = (
                await db.execute(
                    select(L2APolicy).where(
                        L2APolicy.account_id == job.account_id,
                        L2APolicy.status == "ACTIVE",
                    )
                )
            ).scalar_one_or_none()
            if not pol:
                job.status = "failed"
                job.error_report = json.dumps([{"error": "No active L2A policy"}])
                await db.commit()
                return

            writeback_field = pol.link_field_api_name or getattr(
                pol, "lead_lookup_field", None
            )
            overwrite_mode = pol.link_overwrite_mode or "if_empty"
            if not writeback_field:
                job.status = "failed"
                job.error_report = json.dumps(
                    [{"error": "No writeback field configured"}]
                )
                await db.commit()
                return

            # Build SOQL or resume from cursor
            try:
                filters = json.loads(job.filters_json or "[]")
            except Exception:
                filters = []
            ftypes = await _get_field_types(sf, "Lead")
            where = " AND ".join(_build_where(filters, ftypes))
            select_fields = await _select_fields_lead(sf, writeback_field)
            fields_sql = ", ".join(select_fields)
            soql = (
                f"SELECT {fields_sql} FROM Lead"
                + (f" WHERE {where}" if where else "")
                + f" LIMIT {BATCH}"
            )

            # Exclusions
            try:
                exclusions = set(json.loads(job.exclusions_json or "[]"))
            except Exception:
                exclusions = set()

            processed = 0
            applied = 0
            skipped = 0
            errors = 0
            error_lines: List[dict] = []

            # Create ChangeSet for audit
            emitter = EventEmitter()
            cs_id = f"cs_{job.id[:12]}"
            cs = ORMChangeSet(
                id=cs_id,
                account_id=str(job.account_id),
                name="L2A Bulk Apply",
                type="l2a_apply",
                source="ui",
                actor_type="user",
                actor_id=str(job.user_id) if job.user_id else None,
                scope={"filters": filters},
                policy_id=getattr(pol, "id", None),
                status="open",
                attempted_count=0,
                applied_count=0,
                failed_count=0,
            )
            db.add(cs)
            await db.flush()

            # Fetch first page (respect resume)
            if job.last_cursor:
                res = await sf.fetch_next(job.last_cursor)
            else:
                res = await sf.soql(soql)

            async def handle_page(page: dict):
                nonlocal processed, applied, skipped, errors
                records = [r for r in (page.get("records") or [])]
                lead_ids = [
                    r.get("Id")
                    for r in records
                    if r.get("Id") and r.get("Id") not in exclusions
                ]
                page_map = {r.get("Id"): r for r in records if r.get("Id")}
                job.last_page_size = len(records)
                events = []

                # Check for cancel before processing this page
                fresh = await db.get(L2ABulkJob, job.id)
                if fresh and fresh.status == "canceled":
                    job.status = "canceled"
                    job.updated_at = dt.datetime.utcnow()
                    await db.commit()
                    return "canceled"

                for lid in lead_ids:
                    prev = None
                    try:
                        # Check current field
                        cur = await sf.get_record("Lead", lid, fields=[writeback_field])
                        prev = cur.get(writeback_field)

                        should_write = False
                        skip_reason: Optional[str] = None
                        if overwrite_mode == "always":
                            should_write = True
                        elif overwrite_mode == "never":
                            if not prev:
                                should_write = True
                            else:
                                skip_reason = "Field already populated (mode=never)"
                                job.blocked_never = int(job.blocked_never or 0) + 1
                        else:  # if_empty
                            if not prev or prev == job.target_account_id:
                                should_write = True
                            else:
                                skip_reason = (
                                    f"Field already set to {prev} (mode=if_empty)"
                                )
                                job.blocked_if_empty = (
                                    int(job.blocked_if_empty or 0) + 1
                                )

                        if not should_write:
                            skipped += 1
                            continue

                        if job.kind == "manual_link_dryrun":
                            # Dry-run: do not write; count only
                            pass
                        else:
                            # Write
                            from .sf_fls import assert_updateable

                            await assert_updateable(sf, "Lead", writeback_field)
                            await sf.update(
                                "Lead", lid, {writeback_field: job.target_account_id}
                            )
                            applied += 1
                            # Create suggestion record for undo tracking
                            try:
                                s = L2ASuggestion(
                                    policy_id=getattr(pol, "id", None),
                                    lead_id=lid,
                                    account_id=job.target_account_id,
                                    explanation="manual-bulk",
                                    status="linked",
                                    applied_field=writeback_field,
                                    previous_value=prev,
                                    applied_value=job.target_account_id,
                                    decided_at=dt.datetime.utcnow(),
                                    decided_by=str(job.user_id)
                                    if job.user_id
                                    else None,
                                    job_id=str(job.id),
                                )
                                db.add(s)
                            except Exception:
                                # Non-critical: continue if suggestion creation fails
                                pass
                            events.append(
                                {
                                    "changeset_id": cs_id,
                                    "object_type": "Lead",
                                    "object_id": lid,
                                    "op": "update_field",
                                    "field": writeback_field,
                                    "before_value": prev,
                                    "after_value": job.target_account_id,
                                    "confidence": 0.0,
                                    "tier": None,
                                    "suggestion_id": None,
                                    "reason_text": "manual-bulk",
                                    "field_score_details": [],
                                    "actor_type": "user",
                                    "actor_id": job.user_id,
                                }
                            )
                    except Exception as e:  # keep going
                        errors += 1
                        try:
                            company, email = await _lead_context(sf, lid, page_map)
                        except Exception:
                            company, email = None, None
                        error_lines.append(
                            {
                                "lead_id": lid,
                                "company": company,
                                "email": email,
                                "prev": prev,
                                "error": str(e),
                            }
                        )

                if events and job.kind != "manual_link_dryrun":
                    await emitter.emit_batch(db, str(job.account_id), events)

                processed += len(lead_ids)
                job.processed = processed
                job.applied = applied
                job.skipped = skipped
                job.error_count = errors
                job.eta_sec = _eta_seconds(
                    job.processed, job.applied, job.started_at, job.total
                )
                job.updated_at = dt.datetime.utcnow()
                await db.commit()

            # First page
            ret = await handle_page(res)
            next_url = res.get("nextRecordsUrl")
            # Persist cursor after first page
            job = await db.get(L2ABulkJob, job.id)
            if job:
                job.last_cursor = next_url
                job.updated_at = dt.datetime.utcnow()
                await db.commit()
            while next_url and ret != "canceled":
                page = await sf.fetch_next(next_url)
                ret = await handle_page(page)
                next_url = page.get("nextRecordsUrl")
                # Persist cursor for resume
                job = await db.get(L2ABulkJob, job.id)
                if job:
                    job.last_cursor = next_url
                    job.updated_at = dt.datetime.utcnow()
                    await db.commit()

            # finalize changeset
            if job.kind != "manual_link_dryrun":
                cs.status = "finalized"
                cs.attempted_count = job.applied + job.skipped + job.error_count
                cs.applied_count = job.applied
                cs.failed_count = job.error_count
                cs.finalized_at = dt.datetime.utcnow()

            # finalize job
            job.ended_at = dt.datetime.utcnow()
            if job.status == "running":
                job.status = "completed"
            if error_lines:
                job.error_report = json.dumps(error_lines[-500:])
            job.updated_at = dt.datetime.utcnow()
            try:
                await notify_job_done(
                    job.account_id,
                    job.id,
                    job.status,
                    job.applied,
                    job.skipped,
                    job.error_count,
                )
            except Exception:
                pass
            # Channel post (best-effort)
            try:
                blocks = job_card(
                    "l2a",
                    job.id,
                    job.status,
                    applied=(job.applied or 0),
                    skipped=(job.skipped or 0),
                    errors=(job.error_count or 0),
                )
                fire_and_forget("l2a", str(job.account_id), blocks)
            except Exception:
                pass
            await db.commit()

        except Exception as e:
            logger.exception("Bulk L2A job failed: %s", e)
            try:
                job.status = "failed"
                job.error_report = json.dumps([{"error": str(e)}])
                job.updated_at = dt.datetime.utcnow()
                await db.commit()
            except Exception:
                pass
            # Channel post (best-effort)
            try:
                blocks = job_card(
                    "l2a",
                    job.id,
                    "failed",
                    applied=(job.applied or 0),
                    skipped=(job.skipped or 0),
                    errors=(job.error_count or 0),
                )
                fire_and_forget("l2a", str(job.account_id), blocks)
            except Exception:
                pass

        # After finishing (either success or fail), try to start next queued job for this tenant
        try:
            await _try_start_next_for_tenant(db, job.account_id)
        except Exception:
            pass


async def _try_start_next_for_tenant(db, account_id: str):
    """Start next queued job for the account if within concurrency limit."""
    try:
        limit = int(os.getenv("L2A_BULK_CONCURRENCY_LIMIT", "1"))
    except Exception:
        limit = 1
    running = (
        await db.execute(
            select(func.count())
            .select_from(L2ABulkJob)
            .where(
                L2ABulkJob.account_id == str(account_id), L2ABulkJob.status == "running"
            )
        )
    ).scalar_one()
    if running >= limit:
        return
    # Oldest queued
    nxt = (
        await db.execute(
            select(L2ABulkJob.id)
            .where(
                L2ABulkJob.account_id == str(account_id), L2ABulkJob.status == "queued"
            )
            .order_by(L2ABulkJob.created_at.asc())
            .limit(1)
        )
    ).scalar_one_or_none()
    if not nxt:
        return
    updated = (
        await db.execute(
            update(L2ABulkJob)
            .where(L2ABulkJob.id == nxt, L2ABulkJob.status == "queued")
            .values(status="running", started_at=dt.datetime.utcnow())
            .returning(L2ABulkJob.id)
        )
    ).scalar_one_or_none()
    await db.commit()
    if updated:
        asyncio.create_task(run_bulk_link_job(updated))
