"""Jelszó-házirend (v36) - a cég Admin-ja a cég felhasználóira: kötelező jelszócsere, a jelszó bonyolultsága, a jelszó-előzmény.
Leírás: docs/JELSZO_HAZIREND.md

MEGRENDELŐI SZABÁLYOK (v36)
    * MINDEN OPCIONÁLIS: a cég Admin-ja kapcsolja be; alapból egyik szabály sincs érvényben (a régi működés: legalább 6 karakter).
    * KÖTELEZŐ JELSZÓCSERE: érvényesség 30-180 nap (ERVENYESSEG_MIN_NAP / ERVENYESSEG_MAX_NAP). A lejárat előtt EMLEKEZTETO_NAP (7) nappal a felhasználó
      értesítő levelet kap jelszóváltoztató hivatkozással (a hivatkozás a belépés után a Profilom jelszó-kártyájára visz; a link önmagában NEM elég jelszót
      cserélni). Lejáratkor a felhasználó be tud lépni, de csak a Profilom oldalig jut (security.get_current_user), amíg új jelszót nem választ.
    * BONYOLULTSÁG: minimum hossz (6-32) + kötelező kisbetű / nagybetű / szám / speciális karakter (külön kapcsolók). A rendszer a cégadminnak jelzi, ha a beállítás
      gyenge (gyengesegek()).
    * ELŐZMÉNY: a legutóbbi N jelszó (a jelenlegit is beleértve) nem használható újra (0 = kikapcsolva, legfeljebb ELOZMENY_MAX).
    * A rendszeradminisztrátorra (Admin + company_id==1) a lejárat nem vonatkozik (a kizárás elleni védelem); a bonyolultság és az előzmény a cég minden felhasználóját érinti.

MŰKÖDÉS
    1. A szabályok a `companies` táblán vannak (jelszo_*), a felhasználón a lejárat (`jelszo_lejarat_at`) és az emlékeztető jelzője (`jelszo_emlekezteto_at`).
       A lejárat előre kiszámolt oszlop, hogy minden kérésnél elég legyen EGY mező ellenőrzése (nincs cég-lekérdezés, amíg nincs lejárat-jelölt).
    2. Jelszó beállításakor MINDEN végpont (saját csere, cégadmin/rendszeradmin csere, aktiválás) hívja: `ellenorzes()` (bonyolultság + előzmény) a beállítás ELŐTT,
       és `uj_jelszo_utan()` UTÁNA (előzmény-mentés, új lejárat). Egyik sem commitol.
    3. A szabályok módosításakor (routers/jelszo_hazirend.py) az `alkalmaz()` újraszámolja a cég felhasználóinak lejáratát, ha a lejárati idő megváltozott.
       Aki így azonnal lejárt volna, TURELMI_NAP (7) nap türelmi időt kap (senki nem zárul ki a bekapcsolás pillanatában, és az emlékeztető is megérkezik).
    4. Az `emlekeztetok()` óránként fut (main.py ütemező, vedett()): a következő EMLEKEZTETO_NAP napban lejáró jelszavúaknak levelet küld (egyszer / lejárati ciklus).
       A levél `rendszer` kategóriájú: a "Szabadságon vagyok" mód alatt is kimegy (biztonsági levél).
"""
import html
import math
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import List, Optional

import email_beallitasok
import email_sor
import models
from database import SessionLocal
from templates_env import HELYI_TZ

ALAP_MIN_HOSSZ = 6                 # a rendszer régi, mindig érvényes also határa (security.jelszo_eleg_eros_e)
MAX_MIN_HOSSZ = 32                 # a cégadmin által előírható legnagyobb minimum hossz (a bcrypt 72 bájtos határa miatt maradunk jóval alatta)
ERVENYESSEG_MIN_NAP = 30
ERVENYESSEG_MAX_NAP = 180          # fél év
EMLEKEZTETO_NAP = 7
TURELMI_NAP = 7
ELOZMENY_MAX = 10
JELSZO_LEJARAT_KIVETEL = ("/profil", "/sajat-jelszo-csere")      # lejárt jelszónál ezek az egyetlen elérhető (bejelentkezett) oldalak


@dataclass
class Szabalyok:
    ervenyesseg_nap: Optional[int] = None      # None = nincs kötelező jelszócsere
    min_hossz: int = ALAP_MIN_HOSSZ
    kisbetu: bool = False
    nagybetu: bool = False
    szam: bool = False
    specialis: bool = False
    elozmeny_db: int = 0                       # 0 = nincs előzmény-ellenőrzés

    @property
    def lejarat_aktiv(self) -> bool:
        return self.ervenyesseg_nap is not None

    @property
    def bonyolultsag_aktiv(self) -> bool:
        return self.min_hossz > ALAP_MIN_HOSSZ or self.kisbetu or self.nagybetu or self.szam or self.specialis

    @property
    def barmi_aktiv(self) -> bool:
        return self.lejarat_aktiv or self.bonyolultsag_aktiv or self.elozmeny_db > 0


def _utc_most() -> datetime:
    return datetime.now(timezone.utc).replace(tzinfo=None)


def _helyi_datum(utc_naiv: datetime) -> str:
    return utc_naiv.replace(tzinfo=timezone.utc).astimezone(HELYI_TZ).strftime("%Y-%m-%d")


def _egesz(ertek, also: int, felso: int, alap: int) -> int:
    try:
        n = int(ertek)
    except (TypeError, ValueError):
        return alap
    return max(also, min(felso, n))


# =============================================================================================
# 1. A SZABÁLYOK
# =============================================================================================
def szabalyok(ceg) -> Szabalyok:
    """A cég (models.Company vagy None) jelszó-szabályai. A tárolt értékek védekező módon szűkítve (egy kézzel elállított érték se törje a belépést)."""
    if ceg is None:
        return Szabalyok()
    nap = getattr(ceg, "jelszo_ervenyesseg_nap", None)
    return Szabalyok(
        ervenyesseg_nap=None if nap is None else _egesz(nap, ERVENYESSEG_MIN_NAP, ERVENYESSEG_MAX_NAP, ERVENYESSEG_MAX_NAP),
        min_hossz=_egesz(getattr(ceg, "jelszo_min_hossz", None), ALAP_MIN_HOSSZ, MAX_MIN_HOSSZ, ALAP_MIN_HOSSZ),
        kisbetu=bool(getattr(ceg, "jelszo_kell_kisbetu", False)),
        nagybetu=bool(getattr(ceg, "jelszo_kell_nagybetu", False)),
        szam=bool(getattr(ceg, "jelszo_kell_szam", False)),
        specialis=bool(getattr(ceg, "jelszo_kell_specialis", False)),
        elozmeny_db=_egesz(getattr(ceg, "jelszo_elozmeny_db", None), 0, ELOZMENY_MAX, 0),
    )


def szabalyok_cegre(db, company_id) -> Szabalyok:
    return szabalyok(db.get(models.Company, company_id) if company_id else None)


def _rendszeradmin(user) -> bool:
    return user.role == "Admin" and user.company_id == 1


def kovetelmenyek(szab: Szabalyok) -> List[str]:
    """A követelmények emberi szövege (űrlapok súgója, hibaüzenet)."""
    lista = [f"legalább {szab.min_hossz} karakter"]
    if szab.kisbetu:
        lista.append("kisbetű")
    if szab.nagybetu:
        lista.append("nagybetű")
    if szab.szam:
        lista.append("szám")
    if szab.specialis:
        lista.append("speciális karakter (pl. ! ? # % - _)")
    return lista


def kovetelmeny_szoveg(szab: Szabalyok) -> str:
    return ", ".join(kovetelmenyek(szab))


# =============================================================================================
# 2. A JELSZÓ ELLENŐRZÉSE (beállítás előtt)
# =============================================================================================
def _specialis_e(c: str) -> bool:
    return not c.isalnum() and not c.isspace()


def bonyolultsag_hibai(szab: Szabalyok, jelszo: str) -> List[str]:
    """A jelszó melyik bonyolultsági szabályt sérti (üres lista = megfelel)."""
    jelszo = jelszo or ""
    hibak = []
    if len(jelszo) < szab.min_hossz:
        hibak.append(f"legalább {szab.min_hossz} karakter hosszú legyen")
    if szab.kisbetu and not any(c.islower() for c in jelszo):
        hibak.append("tartalmazzon kisbetűt")
    if szab.nagybetu and not any(c.isupper() for c in jelszo):
        hibak.append("tartalmazzon nagybetűt")
    if szab.szam and not any(c.isdigit() for c in jelszo):
        hibak.append("tartalmazzon számot")
    if szab.specialis and not any(_specialis_e(c) for c in jelszo):
        hibak.append("tartalmazzon speciális karaktert (pl. ! ? # % - _)")
    return hibak


def _volt_mar(db, user, szab: Szabalyok, jelszo: str) -> bool:
    """Igaz, ha a jelszó a felhasználó legutóbbi `elozmeny_db` jelszava között van (a jelenlegi is beleszámít). bcrypt: legfeljebb ELOZMENY_MAX összehasonlítás."""
    from security import verify_password            # késleltetett import: a security a jelszo_hazirendet kéri be (körkörös import)
    hashek = [user.hashed_password]
    if szab.elozmeny_db >= 2:
        sorok = (db.query(models.JelszoElozmeny.hashed_password).filter(models.JelszoElozmeny.user_id == user.id)
                 .order_by(models.JelszoElozmeny.id.desc()).limit(szab.elozmeny_db - 1).all())
        hashek += [h for (h,) in sorok]
    for h in hashek:
        try:
            if h and verify_password(jelszo, h):
                return True
        except Exception:
            continue                                  # sérült lenyomat: nem akadályozza a jelszócserét
    return False


def ellenorzes(db, user, jelszo: str) -> Optional[str]:
    """A cég jelszó-házirendjének ellenőrzése a felhasználó ÚJ jelszavára. None = megfelel; különben a felhasználónak szóló hibaüzenet.
    A régi, mindig érvényes ellenőrzések (security.jelszo_eleg_eros_e / jelszo_tul_hosszu_e) a HÍVÓ dolga. Nem módosít, nem commitol."""
    szab = szabalyok_cegre(db, user.company_id)
    hibak = bonyolultsag_hibai(szab, jelszo)
    if hibak:
        return "Az új jelszó nem felel meg a cég jelszó-házirendjének: " + ", ".join(hibak) + "."
    # előzmény: csak már aktivált fióknál (az aktiválásra váró fiók jelszava egy senki által nem ismert véletlen lenyomat)
    if szab.elozmeny_db >= 1 and user.aktivalva is not False and _volt_mar(db, user, szab, jelszo):
        if szab.elozmeny_db == 1:
            return "Az új jelszó nem egyezhet a jelenlegivel."
        return f"Ezt a jelszót nemrég már használtad - az utolsó {szab.elozmeny_db} jelszó nem ismételhető."
    return None


# =============================================================================================
# 3. JELSZÓ BEÁLLÍTÁSA UTÁN: előzmény + új lejárat
# =============================================================================================
def lejarat_szamitasa(szab: Szabalyok, user, alap: datetime) -> Optional[datetime]:
    if not szab.lejarat_aktiv or _rendszeradmin(user) or user.aktivalva is False:
        return None
    return alap + timedelta(days=szab.ervenyesseg_nap)


def uj_jelszo_utan(db, user, regi_hash: Optional[str] = None) -> None:
    """A felhasználó jelszavának beállítása UTÁN hívandó (a hívó már beírta a hashed_password-öt és a jelszo_valtozott_at-ot).
    Előzmény: a régi lenyomatot eltesszi (ha a cégnél van előzmény-szabály; aktiváláskor regi_hash=None). Új lejárat: most + érvényesség.
    NEM commitol."""
    szab = szabalyok_cegre(db, user.company_id)
    most = _utc_most()
    if regi_hash and szab.elozmeny_db >= 2:
        db.add(models.JelszoElozmeny(user_id=user.id, hashed_password=regi_hash, letrehozva_at=most))
        db.flush()
        regi = (db.query(models.JelszoElozmeny.id).filter(models.JelszoElozmeny.user_id == user.id)
                .order_by(models.JelszoElozmeny.id.desc()).offset(ELOZMENY_MAX).all())
        if regi:
            db.query(models.JelszoElozmeny).filter(models.JelszoElozmeny.id.in_([i for (i,) in regi])).delete(synchronize_session=False)
    user.jelszo_lejarat_at = lejarat_szamitasa(szab, user, most)
    user.jelszo_emlekezteto_at = None


def alkalmaz(db, ceg) -> int:
    """A cég (módosított) lejárati szabályának alkalmazása a cég minden felhasználójára: újraszámolja a lejáratot. A kiindulás a felhasználó utolsó jelszócseréje
    (jelszo_valtozott_at; ha sosem volt: most). Aki a számítás szerint már lejárt, TURELMI_NAP türelmi időt kap. Kikapcsolt szabálynál a lejáratok törlődnek.
    Az emlékeztető jelzője törlődik, ha a lejárat megváltozott. Visszatér: az érintett (lejárattal rendelkező) felhasználók száma. NEM commitol."""
    szab = szabalyok(ceg)
    most = _utc_most()
    erintett = 0
    for u in db.query(models.User).filter(models.User.company_id == ceg.id).all():
        alap = u.jelszo_valtozott_at
        if alap is not None and alap.tzinfo is not None:
            alap = alap.astimezone(timezone.utc).replace(tzinfo=None)
        uj = lejarat_szamitasa(szab, u, alap or most)
        if uj is not None and uj <= most:
            uj = most + timedelta(days=TURELMI_NAP)
        if u.jelszo_lejarat_at != uj:
            u.jelszo_emlekezteto_at = None
        u.jelszo_lejarat_at = uj
        if uj is not None:
            erintett += 1
    return erintett


# =============================================================================================
# 4. LEJÁRAT: kényszerített csere belépés után
# =============================================================================================
def kenyszeritett_csere_e(db, user) -> bool:
    """Igaz, ha a felhasználónak most KÖTELEZŐ jelszót cserélnie (a jelszava lejárt). A security.get_current_user() hívja, de csak akkor, ha a
    `jelszo_lejarat_at` ki van töltve. Védekező: ha a cég szabálya közben megszűnt / a felhasználó rendszeradmin lett vagy más cégbe került, nem kényszerít."""
    if user.jelszo_lejarat_at is None or user.jelszo_lejarat_at > _utc_most():
        return False
    if _rendszeradmin(user) or user.aktivalva is False:
        return False
    return szabalyok_cegre(db, user.company_id).lejarat_aktiv


def allapot(db, user) -> dict:
    """A Profilom oldal jelszó-kártyájának adatai."""
    szab = szabalyok_cegre(db, user.company_id)
    most = _utc_most()
    lejarat = user.jelszo_lejarat_at if (szab.lejarat_aktiv and not _rendszeradmin(user)) else None
    hatralevo = None
    if lejarat is not None:
        hatralevo = max(0, math.ceil((lejarat - most).total_seconds() / 86400))
    return {
        "szabalyok": szab,
        "kovetelmeny_szoveg": kovetelmeny_szoveg(szab),
        "bonyolultsag_aktiv": szab.bonyolultsag_aktiv,
        "elozmeny_db": szab.elozmeny_db,
        "lejarat_datum": _helyi_datum(lejarat) if lejarat else None,
        "hatralevo_nap": hatralevo,
        "lejart": lejarat is not None and lejarat <= most,
        "kozeleg": lejarat is not None and most < lejarat <= most + timedelta(days=EMLEKEZTETO_NAP),
        "min_hossz": szab.min_hossz,
    }


def belepes_utani_cel(db, user) -> str:
    """Hova menjen a felhasználó a sikeres belépés után. Alapból a modulválasztó; ha a jelszava a következő EMLEKEZTETO_NAP napban lejár, a Profilom
    jelszó-kártyája (az emlékeztető levél hivatkozása ide érkezik: levél -> belépés -> jelszócsere). A lejárt jelszót a get_current_user úgyis ide irányítja."""
    try:
        if user.jelszo_lejarat_at is not None and user.jelszo_lejarat_at <= _utc_most() + timedelta(days=EMLEKEZTETO_NAP) \
                and szabalyok_cegre(db, user.company_id).lejarat_aktiv and not _rendszeradmin(user):
            return "/profil?jelszo=kozeleg#jelszo"
    except Exception:
        pass
    return "/valassz-modult"


# =============================================================================================
# 5. FIGYELMEZTETÉS A CÉGADMINNAK: gyenge beállítás
# =============================================================================================
def becsult_bit(szab: Szabalyok) -> float:
    """A házirendnek MEGFELELŐ legrosszabb jelszó becsült erőssége bitben: hossz x log2(a megengedett karakterkészlet). Ha nincs karakterfajta előírva,
    a legrosszabb eset a csupa számjegy (10 elemű készlet). Durva, de a cégadmin számára érthető becslés (nem szótár-alapú)."""
    keszlet = 0
    if szab.kisbetu:
        keszlet += 26
    if szab.nagybetu:
        keszlet += 26
    if szab.szam:
        keszlet += 10
    if szab.specialis:
        keszlet += 33
    if keszlet == 0:
        keszlet = 10
    return szab.min_hossz * math.log2(keszlet)


def gyengesegek(szab: Szabalyok) -> List[dict]:
    """A beállítás gyenge pontjai a cégadminnak: [{"szint": "danger"|"warning", "szoveg": ...}]. Üres lista = nincs figyelmeztetés."""
    fajtak = sum([szab.kisbetu, szab.nagybetu, szab.szam, szab.specialis])
    bit = becsult_bit(szab)
    lista = []
    if szab.min_hossz < 8:
        lista.append({"szint": "danger", "szoveg": f"A minimum hossz mindössze {szab.min_hossz} karakter - ilyen rövid jelszót könnyen kitalálnak vagy végigpróbálnak. Legalább 8, ideálisan 12 karaktert érdemes előírni."})
    elif szab.min_hossz < 12:
        lista.append({"szint": "warning", "szoveg": f"A minimum hossz {szab.min_hossz} karakter. Legalább 12 karakter biztonságosabb (a hosszabb jelszó többet ér, mint egy rövid, bonyolult)."})
    if fajtak < 3 and szab.min_hossz < 14:
        lista.append({"szint": "danger" if fajtak == 0 else "warning",
                      "szoveg": ("Nincs karakterfajta előírva - a rendszer a csupa kisbetűs vagy csupa számjegyes jelszót is elfogadja." if fajtak == 0 else
                                 f"Csak {fajtak} karakterfajta van előírva. Legalább három fajta (kisbetű, nagybetű, szám, speciális karakter) ajánlott, hacsak nem írsz elő legalább 14 karaktert.")})
    if bit < 40:
        lista.append({"szint": "danger", "szoveg": f"A házirendnek megfelelő legrosszabb jelszó becsült erőssége csak ~{int(bit)} bit - gyenge (ajánlott: legalább 60 bit)."})
    elif bit < 60:
        lista.append({"szint": "warning", "szoveg": f"A házirendnek megfelelő legrosszabb jelszó becsült erőssége ~{int(bit)} bit - közepes (ajánlott: legalább 60 bit)."})
    if szab.elozmeny_db == 0:
        lista.append({"szint": "warning", "szoveg": "Nincs jelszó-előzmény megadva: a felhasználó a lejáratkor visszaválthat a régi jelszavára (a kötelező jelszócsere így nem sokat ér)."}
                     if szab.lejarat_aktiv else
                     {"szint": "warning", "szoveg": "Nincs jelszó-előzmény megadva: a felhasználó a régi jelszavát újra beállíthatja."})
    return lista


# =============================================================================================
# 6. EMLÉKEZTETŐ LEVÉL (ütemező, óránként)
# =============================================================================================
def _emlekezteto_level(db, user, ceg, lejarat: datetime) -> int:
    e = lambda x: html.escape("" if x is None else str(x), quote=True)  # noqa: E731
    szab = szabalyok(ceg)
    nap = max(1, math.ceil((lejarat - _utc_most()).total_seconds() / 86400))
    link = email_beallitasok.betolt(db).alkalmazas_url_vagy_alap.rstrip("/") + "/profil#jelszo"
    torzs = (
        "<div style=\"font-family:Arial,sans-serif;max-width:560px;margin:auto\">"
        "<h2>Hamarosan lejár a jelszavad</h2>"
        f"<p>Kedves {e(user.username)}!</p>"
        f"<p>A(z) <b>{e(ceg.nev)}</b> jelszó-házirendje szerint az EmeLOG jelszavad <b>{e(_helyi_datum(lejarat))}</b> napján lejár "
        f"(<b>{nap} nap múlva</b>). A lejárat után belépéskor csak a jelszócsere oldalig jutsz, amíg új jelszót nem választasz.</p>"
        "<p style=\"text-align:center;margin:28px 0\">"
        f"<a href=\"{e(link)}\" style=\"background-color:#0d6efd;color:#fff;padding:12px 24px;text-decoration:none;border-radius:5px;font-weight:bold\">Jelszó módosítása</a></p>"
        "<p>A gombra kattintva be kell lépned, majd a Profilom oldal <i>Jelszó módosítása</i> részén választhatsz új jelszót. "
        f"Az új jelszóval szemben követelmény: {e(kovetelmeny_szoveg(szab))}."
        + (f" Az utolsó {szab.elozmeny_db} jelszavad nem használható újra." if szab.elozmeny_db >= 2 else
           (" Az új jelszó nem egyezhet a jelenlegivel." if szab.elozmeny_db == 1 else "")) + "</p>"
        f"<p>Ha a gomb nem működik, másold a böngésződbe ezt a címet:<br><span style=\"word-break:break-all\">{e(link)}</span></p>"
        "<p style=\"color:#666;font-size:12px\">Ez egy automatikus értesítés. A jelszavadat a levélben soha nem kérjük el.</p></div>"
    )
    return email_sor.sorba_allit([user.email], "Hamarosan lejár az EmeLOG jelszavad", torzs, kategoria="rendszer",
                                 prioritas=email_sor.PRIORITAS_NORMAL, company_id=user.company_id, db=db)


def emlekeztetok() -> int:
    """Az ütemezett (óránkénti) feladat: aki a következő EMLEKEZTETO_NAP napban lejáró jelszóval rendelkezik, és még nem kapott emlékeztetőt erre a lejáratra,
    levelet kap. Visszatér: a sorba állított levelek száma. Hiba nem dobódik tovább (az ütemezőt nem ejtheti el); a következő óra újra próbál."""
    kuldve = 0
    try:
        db = SessionLocal()
    except Exception as ex:
        print(f"❌ A jelszó-lejárati emlékeztetők: az adatbázis nem érhető el: {type(ex).__name__}: {ex}")
        return 0
    try:
        most = _utc_most()
        U = models.User
        jeloltek = db.query(U).filter(
            U.jelszo_lejarat_at.isnot(None), U.jelszo_lejarat_at > most, U.jelszo_lejarat_at <= most + timedelta(days=EMLEKEZTETO_NAP),
            U.jelszo_emlekezteto_at.is_(None), U.aktiv.is_(True)).all()
        for u in jeloltek:
            if u.aktivalva is False or _rendszeradmin(u):
                continue
            ceg = db.get(models.Company, u.company_id) if u.company_id else None
            if ceg is None or ceg.aktiv is False or not szabalyok(ceg).lejarat_aktiv:
                continue
            if _emlekezteto_level(db, u, ceg, u.jelszo_lejarat_at) >= 1:
                u.jelszo_emlekezteto_at = most
                kuldve += 1
        db.commit()
    except Exception as ex:
        db.rollback()
        kuldve = 0
        print(f"❌ A jelszó-lejárati emlékeztetők sorba állítása sikertelen: {type(ex).__name__}: {ex}")
    finally:
        db.close()
    return kuldve
