"""Tier D — funciones que el mercado no vende: verdad de la oferta + anti-silencio.

No añade pantallas. Vive dentro de Ofertas, ficha y Mis envíos.
"""

from __future__ import annotations

import re
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import Any

from sqlalchemy.orm import Session, joinedload

from app.models import Application, Job, JobScore, User
from app.services.entitlements import user_plan_rank

GHOST_PHRASES = (
    "banco de talentos",
    "bolsa de empleo",
    "bolsa de candidatos",
    "candidatura espontánea",
    "always hiring",
    "evergreen",
    "talent pool",
    "pipeline de talento",
    "sin fecha de cierre",
    "incorporación inmediata a bolsa",
)

COL_INDEX = {
    "madrid": 100,
    "barcelona": 98,
    "bilbao": 90,
    "san sebastian": 94,
    "donostia": 94,
    "palma": 92,
    "valencia": 82,
    "sevilla": 78,
    "malaga": 80,
    "zaragoza": 80,
    "alicante": 76,
    "murcia": 74,
    "granada": 74,
    "cordoba": 72,
    "valladolid": 76,
    "vigo": 75,
    "a coruña": 78,
    "oviedo": 76,
    "remote": 88,
    "remoto": 88,
    "teletrabajo": 88,
    "españa": 86,
    "spain": 86,
    "europe": 95,
    "eu": 95,
}

_NORM = re.compile(r"[^a-z0-9áéíóúüñ]+", re.I)


def _now() -> datetime:
    return datetime.now(timezone.utc)


def _aware(dt: datetime | None) -> datetime | None:
    if dt is None:
        return None
    if dt.tzinfo is None:
        return dt.replace(tzinfo=timezone.utc)
    return dt


def norm_key(title: str | None, company: str | None) -> str:
    t = _NORM.sub(" ", (title or "").lower()).strip()
    c = _NORM.sub(" ", (company or "").lower()).strip()
    return f"{c}|{t}"[:120]


def signal_gates(db: Session, user: User) -> dict[str, bool]:
    rank = user_plan_rank(db, user)
    return {
        "golden": True,  # anzuelo: todo el mundo lo ve
        "twins": rank >= 1,
        "ghost": rank >= 2 or bool(user.is_admin),
        "buying": rank >= 2 or bool(user.is_admin),
        "silence": rank >= 2 or bool(user.is_admin),
        "ics": rank >= 3 or bool(user.is_admin),
    }


def _age_hours(job: Job) -> float | None:
    stamp = _aware(job.posted_at) or _aware(job.scraped_at)
    if not stamp:
        return None
    return max(0.0, (_now() - stamp).total_seconds() / 3600.0)


def golden_window(job: Job) -> dict[str, Any]:
    hours = _age_hours(job)
    if hours is None:
        return {
            "key": "unknown",
            "label": "Sin fecha clara",
            "hours": None,
            "hint": "No sabemos cuándo se publicó. Trátala como si ya hubiera gente dentro.",
        }
    if hours <= 24:
        return {
            "key": "gold",
            "label": "Ventana de oro",
            "hours": round(hours, 1),
            "hint": "Publicada hace menos de 24 h. Quien aplica ahora entra en el primer corte.",
        }
    if hours <= 72:
        return {
            "key": "ok",
            "label": "Aún a tiempo",
            "hours": round(hours, 1),
            "hint": "Lleva 1–3 días. Todavía vale la pena si encaja.",
        }
    if hours <= 24 * 14:
        return {
            "key": "late",
            "label": "Tarde",
            "hours": round(hours, 1),
            "hint": "Lleva más de 3 días. Compensa solo si hay contacto o encaje alto.",
        }
    return {
        "key": "stale",
        "label": "Muy antigua",
        "hours": round(hours, 1),
        "hint": "Más de 2 semanas. Muchos procesos ya están cerrados aunque el anuncio siga vivo.",
    }


def _col_for(location: str, remote: bool) -> tuple[int, str]:
    loc = (location or "").lower()
    if remote or "remote" in loc or "remoto" in loc or "teletrabajo" in loc:
        return COL_INDEX["remote"], "remoto"
    for name, idx in COL_INDEX.items():
        if name in loc:
            return idx, name
    return 86, "españa"


def buying_power(job: Job) -> dict[str, Any] | None:
    salary = job.salary_min or job.salary_max
    if not salary:
        return {
            "disclosed": False,
            "label": "No dicen el sueldo",
            "hint": "Sin cifra, negociar es a ciegas. Pide banda en el primer contacto.",
        }
    col, city = _col_for(job.location or "", bool(job.remote))
    madrid_eq = int(round(salary * 100 / max(1, col)))
    return {
        "disclosed": True,
        "salary": salary,
        "currency": job.salary_currency or "EUR",
        "col_index": col,
        "city": city,
        "madrid_equivalent": madrid_eq,
        "label": f"{salary:,} {(job.salary_currency or 'EUR')} ≈ {madrid_eq:,} € de poder de compra en Madrid".replace(",", "."),
        "hint": "Índice propio España (Madrid = 100). Nadie en InfoJobs te traduce el sueldo a vida real.",
    }


def ghost_risk(job: Job, twin_count: int = 1) -> dict[str, Any]:
    text = f"{job.title or ''} {job.description or ''}".lower()
    score = 0
    reasons: list[str] = []
    hours = _age_hours(job)
    if hours is not None and hours > 24 * 45:
        score += 40
        reasons.append("El anuncio lleva más de 45 días")
    if any(p in text for p in GHOST_PHRASES):
        score += 35
        reasons.append("Habla de bolsa / talent pool / always hiring")
    if not job.salary_min and len(job.description or "") < 380:
        score += 15
        reasons.append("Texto corto y sin sueldo")
    if twin_count >= 4:
        score += 12
        reasons.append(f"La misma plaza aparece en {twin_count} portales")
    raw = job.raw_json if isinstance(job.raw_json, dict) else {}
    if raw.get("demo") or raw.get("bridge") or raw.get("fallback"):
        score += 8
        reasons.append("Conector o demo: no es una ficha original")
    score = min(100, score)
    if score >= 55:
        level = "alta"
        label = "Posible oferta fantasma"
    elif score >= 28:
        level = "media"
        label = "Señales raras"
    else:
        level = "baja"
        label = "Parece oferta real"
    return {"score": score, "level": level, "label": label, "reasons": reasons}


def twin_counts(db: Session, jobs: list[Job]) -> dict[int, int]:
    if not jobs:
        return {}
    companies = {j.company for j in jobs if (j.company or "").strip()}
    if not companies:
        return {j.id: 1 for j in jobs}
    since = _now() - timedelta(days=90)
    rows = (
        db.query(Job.id, Job.title, Job.company)
        .filter(Job.company.in_(companies), Job.scraped_at >= since)
        .limit(2500)
        .all()
    )
    buckets: dict[str, list[int]] = defaultdict(list)
    for rid, title, company in rows:
        buckets[norm_key(title, company)].append(rid)
    out: dict[int, int] = {}
    for j in jobs:
        key = norm_key(j.title, j.company)
        out[j.id] = max(1, len(set(buckets.get(key) or [j.id])))
    return out


def twin_siblings(db: Session, job: Job, limit: int = 6) -> list[dict[str, Any]]:
    key = norm_key(job.title, job.company)
    if not job.company:
        return []
    since = _now() - timedelta(days=90)
    rows = (
        db.query(Job)
        .filter(Job.company == job.company, Job.id != job.id, Job.scraped_at >= since)
        .limit(40)
        .all()
    )
    out = []
    for other in rows:
        if norm_key(other.title, other.company) != key:
            continue
        out.append({"id": other.id, "source": other.source, "url": other.url, "title": other.title})
        if len(out) >= limit:
            break
    return out


def job_signals(
    job: Job,
    *,
    gates: dict[str, bool],
    twin_count: int = 1,
    twins: list[dict[str, Any]] | None = None,
    reasons: list[str] | None = None,
) -> dict[str, Any]:
    window = golden_window(job)
    ghost = ghost_risk(job, twin_count)
    honesty = {
        "salary": bool(job.salary_min or job.salary_max),
        "spain_ok": job.hire_from_spain_ok,
        "english_wall": bool(job.requires_english_fluent),
        "contact": bool(job.contact_email or job.contact_phone or job.contact_linkedin),
    }
    bullets: list[str] = []
    if window["key"] == "gold":
        bullets.append(window["hint"])
    if ghost["level"] != "baja" and gates.get("ghost"):
        bullets.append(ghost["label"] + ": " + (ghost["reasons"][0] if ghost["reasons"] else ""))
    if not honesty["salary"]:
        bullets.append("No publican sueldo.")
    if honesty["english_wall"]:
        bullets.append("Piden inglés fluido.")
    if honesty["spain_ok"] is True:
        bullets.append("Encaja contratar desde España.")
    elif honesty["spain_ok"] is False:
        bullets.append("Puede no contratar desde España.")
    if reasons:
        bullets.extend(list(reasons)[:2])
    buying = buying_power(job) if gates.get("buying") else None
    locked: list[str] = []
    payload: dict[str, Any] = {
        "window": window if gates.get("golden") else None,
        "twins": {"count": twin_count, "siblings": twins or []} if gates.get("twins") else None,
        "ghost": ghost if gates.get("ghost") else None,
        "buying": buying,
        "honesty": honesty,
        "bullets": [b for b in bullets if b][:4],
        "wow": "Ni InfoJobs ni Teal te dicen si el anuncio está vivo, si es el mismo en 4 portales, o cuánto vale de verdad el sueldo.",
    }
    if not gates.get("ghost"):
        locked.append("Detector de ofertas fantasma (Profesional)")
        payload["ghost_teaser"] = "Hay señales de calidad en esta oferta. Desbloquéalas en Profesional."
    if not gates.get("twins"):
        locked.append("Mismo anuncio en varios portales (Esencial)")
    if not gates.get("buying"):
        locked.append("Poder de compra real (Profesional)")
    payload["locked"] = locked
    return payload


def attach_signals_to_jobs(db: Session, user: User, jobs: list[Job], score_map: dict[int, JobScore] | None = None) -> dict[int, dict[str, Any]]:
    gates = signal_gates(db, user)
    counts = twin_counts(db, jobs)
    out: dict[int, dict[str, Any]] = {}
    for job in jobs:
        sc = (score_map or {}).get(job.id)
        reasons = list(sc.reasons or []) if sc else []
        out[job.id] = job_signals(job, gates=gates, twin_count=counts.get(job.id, 1), reasons=reasons)
    return out


def silence_protocol(app: Application, *, gates: dict[str, bool]) -> dict[str, Any]:
    if not gates.get("silence"):
        return {
            "locked": True,
            "teaser": "Tras enviar, te decimos el día exacto del seguimiento y el texto. Eso no existe en Huntr.",
            "upgrade": "/pricing",
        }
    if app.status not in ("applied", "applying", "interested"):
        return {"locked": False, "active": False, "hint": "Cuando la marques como enviada, arranca el protocolo anti-silencio."}
    start = _aware(app.applied_at) or _aware(app.updated_at) or _aware(app.created_at) or _now()
    days = max(0, (_now() - start).days)
    company = (app.job.company if app.job else "") or "el equipo"
    title = (app.job.title if app.job else "") or "la oferta"
    steps = [
        {
            "day": 3,
            "title": "Primer seguimiento",
            "text": (
                f"Hola, el {start.strftime('%d/%m')} envié mi candidatura para {title} en {company}. "
                "Sigo muy interesado/a. ¿Podríais confirmarme si el proceso sigue abierto y el siguiente paso? Gracias."
            ),
        },
        {
            "day": 8,
            "title": "Segundo seguimiento",
            "text": (
                f"Retomo el hilo de {title}. Adjunto de nuevo el CV por si hiciera falta. "
                "Si el perfil no encaja, también agradezco un no: me ayuda a no esperar en vacío."
            ),
        },
        {
            "day": 14,
            "title": "Cierre educado",
            "text": (
                f"Cierro el seguimiento de {title} para no molestar. Si en el futuro reabrís el proceso, "
                "estaré encantado/a de retomar. ¡Suerte con la cobertura!"
            ),
        },
    ]
    due = None
    overdue = []
    upcoming = []
    for step in steps:
        item = {**step, "due": (start + timedelta(days=step["day"])).isoformat()}
        if days >= step["day"]:
            overdue.append(item)
        elif due is None:
            due = item
        else:
            upcoming.append(item)
    next_step = due or (overdue[-1] if overdue else steps[0])
    return {
        "locked": False,
        "active": True,
        "days_since": days,
        "next": next_step,
        "overdue": overdue,
        "upcoming": upcoming,
        "ics": gates.get("ics", False),
        "wow": "El silencio de RRHH no es personal: es un calendario. Te damos el día y el mensaje.",
    }


def mission_today(db: Session, user: User) -> dict[str, Any]:
    gates = signal_gates(db, user)
    score_rows = (
        db.query(JobScore)
        .filter(JobScore.user_id == user.id, JobScore.match_score >= 50)
        .order_by(JobScore.match_score.desc())
        .limit(40)
        .all()
    )
    gold: list[dict[str, Any]] = []
    ghosts: list[dict[str, Any]] = []
    for s in score_rows:
        job = db.get(Job, s.job_id)
        if not job:
            continue
        window = golden_window(job)
        if window["key"] == "gold" and len(gold) < 5:
            gold.append(
                {
                    "id": job.id,
                    "title": job.title,
                    "company": job.company,
                    "score": s.match_score,
                    "window": window["label"],
                }
            )
        ghost = ghost_risk(job)
        if gates.get("ghost") and ghost["level"] == "alta" and len(ghosts) < 4:
            ghosts.append({"id": job.id, "title": job.title, "label": ghost["label"]})
    apps = (
        db.query(Application)
        .options(joinedload(Application.job))
        .filter(Application.user_id == user.id, Application.status.in_(["applied", "applying"]))
        .order_by(Application.updated_at.desc())
        .limit(20)
        .all()
    )
    followups = []
    if gates.get("silence"):
        for app in apps:
            proto = silence_protocol(app, gates=gates)
            if proto.get("active") and proto.get("next"):
                followups.append(
                    {
                        "application_id": app.id,
                        "job_id": app.job_id,
                        "title": app.job.title if app.job else "",
                        "next": proto["next"],
                        "days_since": proto.get("days_since"),
                    }
                )
    followups.sort(key=lambda x: x.get("days_since") or 0, reverse=True)
    return {
        "golden": gold,
        "ghosts": ghosts,
        "followups": followups[:5],
        "gates": gates,
        "wow": "Hoy no es ‘mira el feed’. Es: aplica a las de 24 h, no pierdas tiempo en fantasmas, y sigue a quien no contestó.",
    }


def followup_ics(app: Application, step: dict[str, Any]) -> str:
    due = datetime.fromisoformat(str(step["due"]).replace("Z", "+00:00"))
    if due.tzinfo is None:
        due = due.replace(tzinfo=timezone.utc)
    end = due + timedelta(minutes=25)
    stamp = _now().strftime("%Y%m%dT%H%M%SZ")
    ds = due.strftime("%Y%m%dT%H%M%SZ")
    de = end.strftime("%Y%m%dT%H%M%SZ")
    title = (step.get("title") or "Seguimiento").replace("\n", " ")
    body = (step.get("text") or "").replace("\n", "\\n")
    uid = f"jw-followup-{app.id}-{step.get('day', 0)}@jobsworld.local"
    return (
        "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//JobsWorld//AntiSilencio//ES\r\n"
        "CALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\n"
        f"UID:{uid}\r\nDTSTAMP:{stamp}\r\nDTSTART:{ds}\r\nDTEND:{de}\r\n"
        f"SUMMARY:{title} · JobsWorld\r\nDESCRIPTION:{body}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
    )
