import ipaddress
import json
import re
import secrets
import time
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from threading import Lock

import bcrypt
import jwt
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy.orm import Session

import models
import mfa_utils
from database import get_db
from config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES, ALLOWED_EXTENSIONS, PROXY_HOPS


def get_client_ip(request: Request) -> str:
    """A valódi kliens-IP-t adja vissza (bejelentkezés-védelem, naplók).

    Élesben az alkalmazás Apache reverse proxy mögött fut, ezért request.client.host a proxy saját
    (loopback) címe. A kliens címét az `X-Forwarded-For` (XFF) fejlécből olvassuk - DE NEM AZ ELSŐ
    bejegyzésből (2026-10-04 javítás; korábban az első volt, és az hamisítható):

      A kliens maga is küldhet XFF-et ("X-Forwarded-For: 9.9.9.9"). A proxy ezt nem írja felül, hanem
      HOZZÁFŰZI az általa látott valódi címet:  "9.9.9.9, <valódi kliens>". Az első elem tehát a támadó
      által választott érték (így kijátszható az IP-korlát, vagy egy másik cég IP-je kitiltatható), az
      UTOLSÓ viszont a megbízható proxy által hozzáfűzött, valódi cím.

    A megbízható proxyk számát a PROXY_HOPS beállítás adja (config.py / .env, alapérték 1 = egy Apache):
    a jobbról számított PROXY_HOPS-adik bejegyzést használjuk. Ha PROXY_HOPS=0, a fejlécet figyelmen kívül
    hagyjuk (közvetlen kapcsolat, pl. helyi fejlesztés). Ha a fejléc hiányzik, rövidebb a vártnál, vagy az
    érték nem érvényes IP-cím, a közvetlen kapcsolat címét (request.client.host) adjuk vissza - így
    hamisított/hibás fejléc sosem "találhat ki" új IP-t. FIGYELEM: az alkalmazás portja nem érhető el
    közvetlenül kívülről, csak a proxy-n át (ez az architektúra). Ellenőrzés: docs/LOGIN_VEDELEM.md 9.1."""
    kozvetlen = request.client.host if request.client else "ismeretlen"
    if PROXY_HOPS <= 0:
        return kozvetlen
    forwarded = request.headers.get("x-forwarded-for")
    if not forwarded:
        return kozvetlen
    elemek = [e.strip() for e in forwarded.split(",") if e.strip()]
    if len(elemek) < PROXY_HOPS:
        return kozvetlen
    try:
        return str(ipaddress.ip_address(elemek[-PROXY_HOPS]))
    except ValueError:
        return kozvetlen


def biztonsagos_atiranyitas(cel, alap: str = "/") -> str:
    """Egy felhasználótól érkező "next" (visszairányítási) cél tisztítása - NYÍLT ÁTIRÁNYÍTÁS ellen.

    Csak a SAJÁT oldalon belüli, abszolút útvonalat ("/valami/oldal") engedi át, query-string és
    fragment nélkül. Minden más az `alap`-ra (alapból a főoldal) cserélődik:
      * "//evil.hu" és "/\\evil.hu" - a böngésző ezeket protokoll-relatív / külső címnek értelmezi,
      * "https://evil.hu", "javascript:..." - nem "/"-jellel kezdődik,
      * vezérlőkarakterek (sortörés, tabulátor) - fejléc-injektálás és böngésző-trükkök ellen.
    (Korábban a valaszt_ceget() csak a "?" utáni részt vágta le, így a "//evil.hu" átment.)"""
    if not isinstance(cel, str):
        return alap
    cel = cel.split("?")[0].split("#")[0]
    if not cel.startswith("/") or cel.startswith("//") or "\\" in cel:
        return alap
    if any(ord(c) < 32 or ord(c) == 127 for c in cel):
        return alap
    return cel


# --- /login BRUTE-FORCE VÉDELEM ---
# 2026-10-04 (D szelet): a korábbi, folyamat-memóriában tárolt számláló (workerenként külön, újraindításkor
# nullázódó) átkerült az adatbázisba - lásd login_vedelem.py és docs/LOGIN_VEDELEM.md. Itt már csak
# a get_client_ip() maradt (fent). Az AI-szkennelés korlátozója (lent) változatlan.


# MAGAS (2026-10-03-i külső biztonsági audit, 2. kör - még az 1. köri auditból nyitva maradt
# találat): az AI-szkenneléshez (routers/ai_scan.py) eddig semmilyen korlátozás nem volt - egy
# jogosult (nem csak_olvasas) felhasználó akármennyi kérést küldhetett, ami minden hívásnál egy
# fizetős Anthropic API-hívást indít el, tetszőleges mennyiségű, a céget terhelő költséget
# okozva. A login_rate_limitalva_e() fenti mintáját követi, de userenként (nem IP/email
# szerint) és minden hívást számol - sikeres/sikertelen API-választól függetlenül, mert a
# költség már a hívás elindításakor keletkezik.
_ai_szkenneles_kiserletek = defaultdict(list)
_ai_szkenneles_lock = Lock()
_AI_SZKENNELES_ABLAK_MASODPERC = 300
_AI_SZKENNELES_MAX_KISERLET_USER = 10


def ai_szkenneles_rate_limitalva_e(user_id: int) -> bool:
    """True, ha ez a felhasználó az utolsó 5 percben már elérte a megengedett AI-szkennelés
    számot - ilyenkor a hívó félnek EZT a kérést el kell utasítania, API-hívás nélkül. Ha False-
    szal tér vissza, a kérést egyúttal rögzíti is (a számlálóba beleszámít), ezért hívónként
    (sikertől függetlenül) csak egyszer szabad meghívni egy adott kéréshez."""
    most = time.time()
    kulcs = f"user:{user_id}"
    with _ai_szkenneles_lock:
        lista = _ai_szkenneles_kiserletek[kulcs]
        lista[:] = [t for t in lista if most - t < _AI_SZKENNELES_ABLAK_MASODPERC]
        if len(lista) >= _AI_SZKENNELES_MAX_KISERLET_USER:
            return True
        lista.append(most)
    return False


def is_safe_extension(filename: str) -> bool:
    """Ellenőrzi, hogy a fájl kiterjesztése rajta van-e a fehérlistán."""
    if not filename:
        return False
    return any(filename.lower().endswith(ext) for ext in ALLOWED_EXTENSIONS)


def tartalom_illeszkedik_e_kiterjesztesre(filename: str, fejresz: bytes) -> bool:
    """Alap tartalom-ellenőrzés (fájl-aláírás/"magic byte") a PDF/DOCX/XLSX feltöltésekhez -
    2026-09-24-i külső biztonsági audit találata szerint eddig csak a fájlnév kiterjesztését
    néztük (is_safe_extension), a tényleges tartalmat nem, így egy "PDF"-nek látszó fájl bármi
    lehetett. Nem helyettesíti a teljes formátum-validációt (egy sérült, de helyes fejlécű PDF-et
    is átenged), de a legdurvább álcázást (tetszőleges bináris tartalom PDF/DOCX/XLSX néven)
    kizárja. A `fejresz` elég, ha a fájl első néhány (pl. 8) bájtja - a hívónak nem kell a teljes
    tartalmat memóriába olvasnia csak emiatt, bár a jelenlegi feltöltő végpontok úgyis beolvassák.
    Kép-kiterjesztéseknél nincs dolga (azokat a tomorit_kepet() PIL-alapú tartalmi ellenőrzése
    védi) - ilyenkor True-t ad."""
    if not filename or "." not in filename:
        return False
    kiterjesztes = "." + filename.rsplit(".", 1)[-1].lower()
    if kiterjesztes == ".pdf":
        return fejresz[:5] == b"%PDF-"
    if kiterjesztes in (".docx", ".xlsx"):
        # A modern Office-formátumok (OOXML) ZIP-konténerek - a ZIP fájl-aláírás PK\x03\x04
        # (normál), illetve ritkán PK\x05\x06 (üres archívum) vagy PK\x07\x08 (particionált).
        return fejresz[:4] in (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08")
    return True


def safe_json_for_script(data) -> str:
    """JSON szerializáció <script> tagbe ágyazáshoz: megakadályozza a </script> kitörést (XSS)."""
    return (
        json.dumps(data)
        .replace("<", "\\u003c")
        .replace(">", "\\u003e")
        .replace("&", "\\u0026")
    )


def jelszo_eleg_eros_e(jelszo: str) -> bool:
    """Minimális szerver-oldali jelszóerősség-ellenőrzés. A jelszómezők HTML `minlength="6"`-ja
    eddig csak kliensoldali volt (Burp/curl-lal, vagy egy közvetlen POST-tal megkerülhető) - ez
    a függvény minden jelszót beállító/módosító végponton meghívva zárja be ezt a rést. Csak a
    hosszt nézi (nem kényszerít ki kis/nagybetű vagy szám kombinációt), hogy a meglévő
    felhasználói elvárásokkal (és a kliensoldali szabállyal) összhangban maradjon."""
    return bool(jelszo) and len(jelszo) >= 6 and not jelszo_tul_hosszu_e(jelszo)


def jelszo_tul_hosszu_e(jelszo: str) -> bool:
    """A bcrypt (5.x) 72 bájtnál hosszabb jelszóra ValueError-t dob (nem csonkít) - ez eddig egy kezeletlen
    500-as hibát okozott pl. egy hosszú, ékezetes jelszó-mondat vagy jelszókezelő által generált jelszó
    megadásakor. Most 400-as hibaüzenet lesz belőle. A bájthossz számít (az ékezetes betű 2 bájt)."""
    return len((jelszo or "").encode("utf-8")) > 72


def verify_password(plain_password: str, hashed_password: str):
    return bcrypt.checkpw(
        plain_password.encode('utf-8'),
        hashed_password.encode('utf-8')
    )


# Időzítés-alapú felhasználó-enumeráció elleni védelem (2026-09-24-i külső audit találata): a
# /login korábban nem létező e-mail címnél KIHAGYTA a bcrypt-ellenőrzést (gyors válasz), létező
# címnél lefuttatta (lassabb válasz) - a válaszidő különbségéből kitalálható volt, mely e-mail
# címek regisztráltak a rendszerben. Ez a modul-betöltéskor egyszer kiszámolt, fix hash egy nem
# létező felhasználó esetén is lefuttatandó "üres" bcrypt-ellenőrzéshez kell, hogy a válaszidő a
# két eset között ne különbözzön érdemben.
DUMMY_PASSWORD_HASH = bcrypt.hashpw(b"idozites-vedelmi-dummy-jelszo", bcrypt.gensalt()).decode('utf-8')


def get_password_hash(password: str):
    return bcrypt.hashpw(
        password.encode('utf-8'),
        bcrypt.gensalt()
    ).decode('utf-8')


def create_access_token(data: dict):
    to_encode = data.copy()
    most = datetime.now(timezone.utc)
    expire_time = most + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    # "iat" (kiállítás időpontja) - a get_current_user() ezt veti össze a felhasználó
    # jelszo_valtozott_at mezőjével a JWT-visszavonáshoz (l. ott a részletes megjegyzést).
    # MIKROSZEKUNDUM-PONTOS (tört szám, RFC 7519 NumericDate): ha datetime-ot adnánk át, a PyJWT egész másodpercre csonkolná, a jelszo_valtozott_at viszont
    # mikroszekundumos - így egy a jelszóváltással ugyanabban a másodpercben kiadott ÚJ token is "régebbinek" látszana, és tévesen elutasítanánk (v40, tests/t_token_iat.py).
    to_encode.update({"exp": expire_time, "iat": most.timestamp()})
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt


# --- EGYIDEJŰ BELÉPÉS TILTÁSA (2026-10-05, v32 - docs/EGYIDEJU_BELEPES.md) ----------------------------------------
# Szabály (megrendelői döntés): egy fiók egyszerre csak EGY helyen lehet bejelentkezve, MINDENKIRE, a rendszeradminisztrátor
# kivételével; az ÚJ belépés dobja ki a régit. A munkamenet-azonosító (sid) a JWT-ben és a users.aktiv_munkamenet oszlopban van.
# A kidobott felhasználó NEM kapja meg a tényleges okot, csak egy hibakódot (E-LIC-01, l. hibakodok.py: "Licenszkorlát átlépve
# (szimultán bejelentkezés)"); a rendszeradminisztrátorok e-mailben értesülnek (szimultan_belepes_ertesites).
HIBAKOD_SZIMULTAN = "E-LIC-01"


def munkamenet_inditasa(db: Session, user, ip: str):
    """Új munkamenetet indít a felhasználónak: új véletlen azonosítót ír a felhasználó sorába (ezzel a korábbi munkamenet tokenje
    azonnal érvénytelen lesz), és visszaadja azt a JWT-be írandó `sid`-et. A rendszeradminisztrátornak None-t ad (neki nincs
    korlátja, és a meglévő munkameneteit sem érinti). Ha a korábbi munkamenet még érvényes lett volna (a token élettartamán belül
    indult), a Jogosultsági naplóba bejegyzést teszünk: ez a bizonyíték, ha egy fiókot több ember használ. COMMITOL."""
    if is_rendszeradminisztrator(user):
        return None
    most = datetime.now(timezone.utc).replace(tzinfo=None)               # a DB-ben tz nélküli UTC van
    regi_sid, regi_ip, regi_kezdet = user.aktiv_munkamenet, user.munkamenet_ip, user.munkamenet_kezdete_at
    sid = secrets.token_hex(16)
    user.aktiv_munkamenet = sid
    user.munkamenet_kezdete_at = most
    user.munkamenet_ip = (ip or "")[:64]
    if regi_sid and regi_kezdet and regi_kezdet > most - timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES):
        db.add(models.AuditLog(
            company_id=user.company_id,
            user_id=user.id,
            field_name="Bejelentkezés",
            old_value="-",
            new_value=(f"Egyidejű belépés ({HIBAKOD_SZIMULTAN}): az új belépés ({ip}) megszüntette a korábbi, még érvényes munkamenetet "
                       f"({regi_ip or '?'}, indult: {regi_kezdet.strftime('%Y-%m-%d %H:%M')} UTC)")
        ))
        szimultan_belepes_ertesites(db, user, ip, regi_ip, regi_kezdet)
    db.commit()
    return sid


def szimultan_belepes_ertesites(db: Session, user, ip: str, regi_ip, regi_kezdet) -> None:
    """E-LIC-01: levél a rendszeradminisztrátoroknak, hogy egy fiókot egyidejűleg két helyről használtak (licenckorlát átlépése).
    OPCIONÁLIS: csak akkor megy, ha a rendszeradmin bekapcsolta (email_sor.szimultan_ertesites_be_van; alapból KI) - a Jogosultsági
    naplóba a szimultán belépés a kapcsolótól függetlenül MINDIG bekerül (l. munkamenet_inditasa).
    ÁRVÍZ-VÉDELEM: ha két ember percenként kidobja egymást, az nem jelenthet percenként levelet - fiókonként NAPONTA EGY levél megy
    (a további esetek a Jogosultsági naplóban látszanak). A hiba SOHA nem akaszthatja meg a belépést: itt minden kivétel elnyelődik.
    NEM commitol (a hívó commitol a naplóbejegyzéssel együtt)."""
    try:
        import html
        import email_beallitasok
        import email_sor
        import login_vedelem
        from hibakodok import HIBAKODOK
        from templates_env import HELYI_TZ

        if not email_sor.szimultan_ertesites_be_van(db):
            return                           # a levél opcionális (alapból ki): a Jogosultsági naplóba a bejegyzés ettől függetlenül bekerült
        cimek = login_vedelem.rendszeradmin_cimek(db)
        if not cimek:
            return
        ma = datetime.now(HELYI_TZ).date()
        if not email_sor.kikuldheto_e_es_rogzit(db, f"szimultan:{user.id}", user.company_id, 1, ma):
            return
        _e = lambda x: html.escape("" if x is None else str(x), quote=True)  # noqa: E731    (a felhasználónév/cégnév kérésből jöhet)
        ceg = db.get(models.Company, user.company_id) if user.company_id else None
        link = email_beallitasok.betolt(db).alkalmazas_url_vagy_alap.rstrip("/") + "/felhasznalok"
        regi = regi_kezdet.replace(tzinfo=timezone.utc).astimezone(HELYI_TZ).strftime("%Y-%m-%d %H:%M") if regi_kezdet else "?"
        torzs = (
            f"<p><b>{_e(HIBAKODOK[HIBAKOD_SZIMULTAN])}</b> (Hibakód: {_e(HIBAKOD_SZIMULTAN)})</p>"
            f"<p>A(z) <b>{_e(user.email)}</b> fiókot ({_e(user.username)}"
            f"{', ' + _e(ceg.nev) if ceg is not None else ''}) egyidejűleg két helyről használták: "
            f"az új belépés ({_e(ip)}) kidobta a korábbi, még érvényes munkamenetet ({_e(regi_ip or '?')}, indult: {_e(regi)} helyi idő).</p>"
            f"<p>Egy fiók egyszerre csak egy helyen használható; a gyakori ismétlődés arra utalhat, hogy a cég a fiókot több személy között osztja meg "
            f"(licenckorlát átlépése). Fiókonként naponta legfeljebb egy ilyen levelet küldünk; a további esetek a Jogosultsági naplóban láthatók.</p>"
            f"<p><a href=\"{_e(link)}\">Felhasználók</a></p>"
        )
        email_sor.sorba_allit(cimek, f"{HIBAKOD_SZIMULTAN}: {HIBAKODOK[HIBAKOD_SZIMULTAN]}", torzs, kategoria="rendszer",
                              prioritas=email_sor.PRIORITAS_SURGOS, company_id=user.company_id, db=db)
    except Exception as e:                                           # pragma: no cover - védőháló: a belépés nem akadhat el rajta
        print(f"❌ {HIBAKOD_SZIMULTAN} értesítés sikertelen: {type(e).__name__}: {e}")


def belepesi_token(db: Session, user, ip: str, mfa: bool = False) -> str:
    """A TELJES munkamenet tokenje belépés után (jelszó vagy 2FA-kód elfogadása után): új munkamenetet indít, és sid-del adja ki.
    Minden belépési pont ezt használja (routers/auth.py, routers/mfa.py), hogy a korlát ne legyen megkerülhető."""
    adat = {"sub": user.email}
    if mfa:
        adat["mfa"] = True
    sid = munkamenet_inditasa(db, user, ip)
    if sid:
        adat["sid"] = sid
    return create_access_token(adat)


def munkamenet_megszuntetese(db: Session, user) -> None:
    """Kilépéskor: a felhasználó munkamenet-azonosítója törlődik, így a (esetleg máshová átmásolt) token sem használható tovább.
    A rendszeradminisztrátornak nincs mit törölni. COMMITOL (a hívó commitoljon, ha a naplóbejegyzést is írja)."""
    if is_rendszeradminisztrator(user):
        return
    user.aktiv_munkamenet = None
    user.munkamenet_kezdete_at = None
    user.munkamenet_ip = None


def munkamenet_ervenyes_e(user, payload: dict) -> str:
    """A token munkamenet-azonosítóját veti össze a felhasználó soráéval. Visszatér: "ok" (érvényes, vagy a rendszeradminra nem
    vonatkozik), "masik_helyen" (a tokennek van sid-je, de azóta máshol léptek be: ez a kidobás), "lejart" (nincs sid vagy nincs
    érvényes munkamenet: pl. a bevezetés előtt kiadott token, vagy kilépés után). A hívó dönt, mit mutat."""
    if is_rendszeradminisztrator(user):
        return "ok"
    token_sid = payload.get("sid")
    if not token_sid or not user.aktiv_munkamenet:
        return "lejart"
    if secrets.compare_digest(str(token_sid), user.aktiv_munkamenet):
        return "ok"
    return "masik_helyen"


# --- KÉTLÉPCSŐS HITELESÍTÉS: az átmeneti ("mfa_pending") token (2026-10-04, 2FA 2. fázis) ---------------
# A jelszó helyes megadása után, de a 2FA-kód beírása ELŐTT a felhasználó csak ezt a rövid életű tokent kapja,
# KÜLÖN sütiben (MFA_PENDING_COOKIE, csak a /mfa útvonalra). Ez NEM munkamenet-token: a `typ` mező miatt
# a get_current_user() visszautasítja - különben a jelszó ismerője egyszerűen átmásolhatná az
# `access_token` sütibe, és a második faktor kikerülhető volna (ez az egész védelem kulcspontja!).
MFA_PENDING_COOKIE = "mfa_pending"
MFA_PENDING_PERC = 5            # a kód beírásához (cel="kod")
MFA_BEALLITAS_PERC = 15         # a QR-beállításhoz (cel="beallitas"): az alkalmazás telepítése több időt vehet igénybe


def create_mfa_pending_token(email: str, cel: str = "kod") -> str:
    """Az átmeneti token. `cel`: "kod" (a belépéshez kódot kérünk) vagy "beallitas" (a felhasználónak még nincs
    2FA-beállítása: a QR-oldalra irányítjuk, 3. fázis). A két cél külön végpontokhoz tartozik, egymás helyett nem jó."""
    most = datetime.now(timezone.utc)
    perc = MFA_BEALLITAS_PERC if cel == "beallitas" else MFA_PENDING_PERC
    return jwt.encode(
        {"sub": email, "typ": "mfa_pending", "cel": cel, "exp": most + timedelta(minutes=perc), "iat": most.timestamp()},   # iat: mikroszekundum-pontos (l. create_access_token)
        SECRET_KEY, algorithm=ALGORITHM)


def decode_mfa_pending_token(token: str):
    """Az átmeneti token tartalma, vagy None (hiányzik / lejárt / hamis / nem átmeneti típusú)."""
    if not token:
        return None
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    except Exception:
        return None
    return payload if payload.get("typ") == "mfa_pending" else None


def sanitize_filename(filename):
    """Kiberbiztonsági védelem Path Traversal ellen"""
    tiszta_nev = filename.replace(' ', '_')
    biztonsagos_nev = re.sub(r'[^a-zA-Z0-9_\-\.]', '', tiszta_nev)
    return biztonsagos_nev


def get_current_user(request: Request, db: Session = Depends(get_db)):
    token = request.cookies.get("access_token")
    if not token:
        raise HTTPException(
            status_code=status.HTTP_303_SEE_OTHER,
            headers={"Location": "/login"}
        )

    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        email = payload.get("sub")

        # Csak "sima" munkamenet-token fogadható el: a `typ` mezős tokenek (pl. az átmeneti mfa_pending, ami a
        # jelszó után, a 2FA-kód ELŐTT kerül kiadásra) soha nem jogosítanak belépésre.
        if payload.get("typ") is not None:
            raise HTTPException(
                status_code=status.HTTP_303_SEE_OTHER,
                headers={"Location": "/login"}
            )

        user = db.query(models.User).filter(models.User.email == email).first()
        # aktivalva is False: az aktiválásra váró fiók (E szelet, aktivalas_utils.py) semmire nem használható
        if not user or not user.aktiv or user.aktivalva is False:
            raise HTTPException(
                status_code=status.HTTP_303_SEE_OTHER,
                headers={"Location": "/login"}
            )

        # JWT-visszavonás jelszóváltáskor (2026-10-03-i biztonsági audit, 3. fázis): korábban egy
        # ellopott/korábbi JWT a lejáratáig (ACCESS_TOKEN_EXPIRE_MINUTES) érvényes maradt akkor is,
        # ha a felhasználó közben (saját maga vagy egy admin által) jelszót váltott - egy ellopott
        # token tehát jelszóváltással sem volt visszavonható. Ha a jelszó a token kiállítása
        # (iat) UTÁN változott, a token már érvénytelen - újra be kell jelentkezni. NULL
        # jelszo_valtozott_at (még sosem váltott jelszót / e javítás előtt létező sor) esetén
        # nincs mit visszavonni, minden régi token érvényes marad.
        if user.jelszo_valtozott_at is not None:
            iat = payload.get("iat")
            if iat is None or datetime.fromtimestamp(iat, tz=timezone.utc).replace(tzinfo=None) < user.jelszo_valtozott_at:
                raise HTTPException(
                    status_code=status.HTTP_303_SEE_OTHER,
                    headers={"Location": "/login"}
                )

        # KÉTLÉPCSŐS HITELESÍTÉS KIKÉNYSZERÍTÉSE (2FA 2. fázis): ha a felhasználóra kötelező a 2FA (l.
        # mfa_utils.szukseges_e), csak az a token érvényes, amelyet a kód beírása UTÁN adtunk ki (`mfa`
        # mező). Így egy, a kapcsoló bekapcsolása előtt kiadott (kód nélküli) token is érvénytelenné válik
        # - legfeljebb mfa_utils.GYORSITOTAR_MASODPERC késéssel -, és a sima jelszavas token sem elég.
        # Ha sehol nem kötelező (alapállapot), ez kérésenként nem jár extra lekérdezéssel.
        if not payload.get("mfa") and mfa_utils.szukseges_e(db, user, gyorsitotar=True):
            raise HTTPException(
                status_code=status.HTTP_303_SEE_OTHER,
                headers={"Location": "/login"}
            )

        # EGYIDEJŰ BELÉPÉS TILTÁSA (v32): a tokenben lévő munkamenet-azonosítónak egyeznie kell a felhasználóén. Ha azóta máshol is
        # belépett (az új belépés dobja ki a régit), a felhasználó a bejelentkező oldalra kerül, és megmondjuk neki, miért.
        # A rendszeradminisztrátorra nem vonatkozik.
        munkamenet = munkamenet_ervenyes_e(user, payload)
        if munkamenet == "masik_helyen":
            raise HTTPException(
                status_code=status.HTTP_303_SEE_OTHER,
                headers={"Location": f"/login?hibakod={HIBAKOD_SZIMULTAN}"}
            )
        if munkamenet != "ok":
            raise HTTPException(
                status_code=status.HTTP_303_SEE_OTHER,
                headers={"Location": "/login"}
            )

        # JELSZÓ-HÁZIREND (v36, docs/JELSZO_HAZIREND.md): lejárt jelszóval a felhasználó be tud lépni, de csak a Profilom jelszó-kártyájáig jut, amíg
        # új jelszót nem választ. Gyors út: ha a `jelszo_lejarat_at` üres (alapállapot: nincs házirend), nincs extra lekérdezés.
        if user.jelszo_lejarat_at is not None and request.url.path not in ("/profil", "/sajat-jelszo-csere"):
            import jelszo_hazirend          # késleltetett import: a jelszo_hazirend a security-ből is importál
            if jelszo_hazirend.kenyszeritett_csere_e(db, user):
                raise HTTPException(
                    status_code=status.HTTP_303_SEE_OTHER,
                    headers={"Location": "/profil?jelszo=lejart#jelszo"}
                )

        return user

    except HTTPException:
        raise                       # a fenti, szándékos átirányítások (a /login címük eltérhet) változatlanul mennek tovább
    except Exception:
        raise HTTPException(
            status_code=status.HTTP_303_SEE_OTHER,
            headers={"Location": "/login"}
        )


def get_current_user_optional(request: Request, db: Session = Depends(get_db)):
    """Mint a get_current_user, de sosem dob kivételt/irányít át - None-t ad vissza, ha nincs
    érvényes bejelentkezés. Kizárólag olyan nyilvános oldalaknál használandó (pl. a "/" alatti
    marketing kezdőlap), ahol a bejelentkezett és a bejelentkezetlen látogatónak is más tartalom
    jár, tehát a hiányzó bejelentkezés nem hiba, hanem az egyik érvényes állapot."""
    token = request.cookies.get("access_token")
    if not token:
        return None

    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        if payload.get("typ") is not None:
            return None                      # átmeneti (mfa_pending) token: nem munkamenet
        email = payload.get("sub")
        user = db.query(models.User).filter(models.User.email == email).first()
        if not user or not user.aktiv or user.aktivalva is False:
            return None
        if not payload.get("mfa") and mfa_utils.szukseges_e(db, user, gyorsitotar=True):
            return None                      # kötelező 2FA mellett a kód nélküli token nem számít bejelentkezettnek
        if munkamenet_ervenyes_e(user, payload) != "ok":
            return None                      # v32: másik helyen is beléptek (vagy nincs érvényes munkamenet): ez a token nem számít
        return user
    except Exception:
        return None


def get_working_company_id(current_user, request: Request):
    """Szuper-Admin és Külsős Szakértő funkció: Ellenőrzi és kezeli az aktív cégkörnyezetet.
    Elsőként a biztonságos Cookie-ból olvassuk ki (ha a böngészőben friss a váltás), ennek
    hiányában a felhasználóhoz tartósan (adatbázisban) mentett legutóbbi cégválasztásra esünk
    vissza - így új bejelentkezés után vagy más eszközön is automatikusan a legutóbb ténylegesen
    használt céget mutatja a rendszer, nem mindig a felhasználó saját (company_id) cégét."""
    if current_user.role in ["Admin", "Emelőgép szakértő", "Emelőgép vizsgáló", "Emelőgép ügyintéző", "Karbantartó"]:
        def _valaszthato_e(view_id) -> bool:
            # KRITIKUS: itt KORÁBBAN `current_user.role == "Admin"` állt - ez BÁRMELYIK
            # ügyfélcég saját Admin-ját (nem csak a valódi rendszeradminisztrátort) átengedte,
            # ami egy hamisított "workspace_id" cookie-val (semmilyen extra jogosultság nem
            # kellett hozzá) teljes hozzáférést adott BÁRMELYIK MÁSIK cég adataihoz, minden
            # modulban (EBK/Emelőgép/VIVÜ) - élő teszttel igazolt, kritikus súlyosságú hiba
            # volt. Csak a valódi rendszeradminisztrátor (Admin szerepkör ÉS company_id==1)
            # kaphat korlátlan cégváltási jogot, lásd is_rendszeradminisztrator().
            is_admin = is_rendszeradminisztrator(current_user)
            is_allowed_company = (view_id in [c.id for c in current_user.allowed_companies])
            is_own_company = (view_id == current_user.company_id)
            return is_admin or is_allowed_company or is_own_company

        workspace_id = request.cookies.get("workspace_id")
        if workspace_id:
            try:
                view_id = int(workspace_id)
                if _valaszthato_e(view_id):
                    return view_id
            except ValueError:
                pass

        if current_user.utolso_ceg_id and _valaszthato_e(current_user.utolso_ceg_id):
            return current_user.utolso_ceg_id

    return current_user.company_id


def apply_user_data_scope_filter(query, current_user):
    """Szigorú adatszeparáció a Listáknál és Keresőknél"""
    if current_user.role in ["Admin"]:
        return query

    if current_user.hozzaferes_szint == "TELEPHELY":
        site_ids = []
        for s in current_user.allowed_sites:
            site_ids.append(s.id)

        return query.filter(models.Machine.site_id.in_(site_ids))

    elif current_user.hozzaferes_szint == "GEP":
        machine_ids = []
        for m in current_user.allowed_machines:
            machine_ids.append(m.id)

        return query.filter(models.Machine.id.in_(machine_ids))

    return query


def is_company_active(company_id: int, db: Session) -> bool:
    """Inaktivált (törölt ügyfélnek szánt) cégeknél minden szerkesztés tiltott - csak megtekintés/export marad."""
    ceg = db.query(models.Company).filter(models.Company.id == company_id).first()
    return bool(ceg and ceg.aktiv)


# --- Közös írási kapu (J-09, v65) --------------------------------------------------------------------
# Minden CÉG-OLDALI író (POST/PUT/DELETE) route ugyanazt a két szabályt kell, hogy betartsa a szerepkör-ellenőrzés
# UTÁN: (1) az inaktivált cégnél nincs szerkesztés (csak megtekintés/export), (2) a csak_olvasas fiók nem ír.
# Korábban ezt minden route saját kézzel ismételte, és 19 helyen kimaradt (JOGOSULTSAGOK.md J-09) - ezért van
# egyetlen függvény, amit a tests/t_jog_j09.py minden érintett route-on ellenőriz.
# A visszaadott kulcs egyben a már használt "?hiba=..." átirányítási paraméter értéke is (lásd ebk.py).
IRASI_TILTAS_INAKTIV_CEG = "inaktiv_ceg"
IRASI_TILTAS_CSAK_OLVASAS = "csak_olvasas"
IRASI_TILTAS_UZENETEK = {
    IRASI_TILTAS_INAKTIV_CEG: "Ez a cég inaktív státuszú, szerkesztés nem lehetséges!",
    IRASI_TILTAS_CSAK_OLVASAS: "Csak megtekintési jogosultsággal rendelkezel, szerkesztés nem lehetséges!",
}
IRASI_TILTAS_LISTA_URL = {
    IRASI_TILTAS_INAKTIV_CEG: "/lista?inaktiv_ceg=1",
    IRASI_TILTAS_CSAK_OLVASAS: "/lista?csak_olvasas=1",
}


def irasi_tiltas_oka(current_user, company_id: int, db: Session):
    """None, ha a felhasználó írhat a megadott cég adataiba; különben a tiltás oka
    (IRASI_TILTAS_INAKTIV_CEG vagy IRASI_TILTAS_CSAK_OLVASAS). A sorrend (előbb a cég, aztán a fiók)
    megegyezik a korábbi kézi ellenőrzésekével. A RENDSZERADMIN-only route-okra NEM erre van szükség:
    az RA inaktív cégen is dolgozik (aktiválás, törlés), ott csak a csak_olvasas számít."""
    if not is_company_active(company_id, db):
        return IRASI_TILTAS_INAKTIV_CEG
    if current_user.csak_olvasas:
        return IRASI_TILTAS_CSAK_OLVASAS
    return None


def napi_ellenorzes_aktiv_e(company_id: int, db: Session) -> bool:
    """J-14 (v75): be van-e kapcsolva a cégben a napi (műszakkezdő) ellenőrzés (Beállítások / "NapiEllenorzesAktiv",
    a /kapcsol-napi-ellenorzes route írja: a sor megléte = bekapcsolva). Korábban a kapcsoló csak megjelent a
    beállítások oldalon, a szerver nem érvényesítette."""
    return db.query(models.Dictionary).filter(
        models.Dictionary.company_id == company_id,
        models.Dictionary.kategoria == "NapiEllenorzesAktiv"
    ).first() is not None


def sugo_engedelyezve(company_id: int, db: Session) -> bool:
    """Cégenként kapcsolható: megjelenjenek-e a súgó buborékok és a Használati útmutató oldal -
    kizárólag rendszeradmin engedélyezi/tiltja a Cégek oldalon."""
    beallitas = db.query(models.Dictionary).filter(
        models.Dictionary.company_id == company_id,
        models.Dictionary.kategoria == "SugoEngedelyezve"
    ).first()
    return bool(beallitas and beallitas.ertek == "True")


def get_azonositas_mod(company_id: int, db: Session) -> str:
    """Cégenként beállítható géppazonosítási mód a matricákhoz: 'QR', 'NFC' vagy 'Mindketto'. Alapértelmezett: QR."""
    beallitas = db.query(models.Dictionary).filter(
        models.Dictionary.company_id == company_id,
        models.Dictionary.kategoria == "AzonositasMod"
    ).first()
    if beallitas and beallitas.ertek in ("QR", "NFC", "Mindketto"):
        return beallitas.ertek
    return "QR"


def is_rendszeradminisztrator(current_user) -> bool:
    """A rendszeradminisztrátor (Admin szerepkör, az Alapértelmezett Cég / company_id==1
    alá tartozó felhasználó) minden modulhoz automatikusan, korlátozás nélkül hozzáfér."""
    return current_user.role == "Admin" and current_user.company_id == 1


def is_company_accessible(current_user, company_id: int) -> bool:
    """Igaz, ha a felhasználó jogosult az adott céghez hozzáférni: a valódi rendszeradmin
    bármelyikhez, mindenki más csak a saját cégéhez vagy a neki kifejezetten kiosztott
    (allowed_companies) cégekhez - FONTOS: egy céges Admin (role=='Admin', de más company_id)
    NEM automatikusan jogosult minden céghez, csak mert a szerepköre 'Admin'."""
    if is_rendszeradminisztrator(current_user):
        return True
    if company_id == current_user.company_id:
        return True
    return company_id in [c.id for c in current_user.allowed_companies]


def get_company_modul_kodok(company_id: int, db: Session) -> set:
    """Melyik modulokat engedélyezte a rendszeradminisztrátor az adott cégnek (előfizetés-szintű kapu)."""
    ceg = db.query(models.Company).filter(models.Company.id == company_id).first()
    if not ceg:
        return set()
    return {m.kod for m in ceg.engedelyezett_modulok if m.aktiv}


def get_user_modul_kodok(current_user, db: Session) -> set:
    """A felhasználó ténylegesen elérhető moduljai: a cég által engedélyezettek metszete
    azzal, amit a felhasználóhoz konkrétan hozzárendeltek. Rendszeradminisztrátornak
    korlátozás nélkül minden aktív modul jár."""
    if is_rendszeradminisztrator(current_user):
        return {m.kod for m in db.query(models.Modul).filter(models.Modul.aktiv == True).all()}
    ceg_modulok = get_company_modul_kodok(current_user.company_id, db)
    sajat_modulok = {m.kod for m in current_user.sajat_modulok if m.aktiv}
    return ceg_modulok & sajat_modulok


def van_e_tobb_modulja(current_user, db: Session) -> bool:
    """A modulváltó gomb csak akkor legyen látható, ha ténylegesen van hova váltani -
    rendszeradminnak mindig (a rendszeradminisztráció csempe miatt), másnak csak ha 1-nél
    több aktív modulhoz van hozzáférése."""
    if is_rendszeradminisztrator(current_user):
        return True
    return len(get_user_modul_kodok(current_user, db)) > 1


def modul_elerheto_a_munkakornyezetben(current_user, modul_kod: str, working_company_id: int, db: Session) -> bool:
    """J-15 (v72): a modul-kapu a felhasználó SAJÁT cége mellett a kiválasztott MUNKAKÖRNYEZET cégének előfizetését is nézi.
    Korábban csak a saját cég moduljait ellenőrizte, így a külsős szakértő olyan ügyfélcég modulját is elérte, amely nem
    fizetett elő rá. A rendszeradminisztrátor korlátlan (minden aktív modul). A saját cégben dolgozónál a két vizsgálat
    ugyanaz, tehát náluk semmi nem változik."""
    if modul_kod not in get_user_modul_kodok(current_user, db):
        return False
    if is_rendszeradminisztrator(current_user) or working_company_id == current_user.company_id:
        return True
    return modul_kod in get_company_modul_kodok(working_company_id, db)


def munkakornyezet_visszaallitasa_get(request: Request, current_user):
    """Önjavító válasz, ha a kiválasztott munkakörnyezet cégének nincs meg a kért modulja: a munkakörnyezetet a felhasználó
    SAJÁT cégére állítja vissza (a modul-ellenőrzés ott már átment, tehát nincs átirányítási hurok), és ugyanarra az oldalra
    irányít vissza. A visszatérési érték (url, Set-Cookie fejléc) a hívó dolga: a HTTPException / RedirectResponse összerakja."""
    cel = request.url.path + (("?" + request.url.query) if request.url.query else "")
    sutik = f"workspace_id={current_user.company_id}; Max-Age=86400; Path=/; HttpOnly; Secure; SameSite=lax"
    return cel, sutik


def require_modul(modul_kod: str):
    """FastAPI dependency-gyár: csak akkor engedi tovább a kérést, ha a felhasználónak
    van hozzáférése a megadott modulhoz - egyébként a modul-választó oldalra irányít.
    J-15 (v72): a kiválasztott munkakörnyezet cégének is elő kell fizetnie a modulra. Ha nem (és a saját cégében
    van meg a modul), GET kérésnél a munkakörnyezet a saját cégre áll vissza és az oldal újratöltődik, egyéb
    metódusnál (POST...) 403 - hogy egy másik cég modul nélküli adatába ne lehessen se olvasni, se írni."""
    def _dependency(
        request: Request,
        db: Session = Depends(get_db),
        current_user: models.User = Depends(get_current_user)
    ):
        if modul_kod not in get_user_modul_kodok(current_user, db):
            raise HTTPException(
                status_code=status.HTTP_303_SEE_OTHER,
                headers={"Location": "/valassz-modult"}
            )
        working_company_id = get_working_company_id(current_user, request)
        if not modul_elerheto_a_munkakornyezetben(current_user, modul_kod, working_company_id, db):
            if request.method == "GET":
                cel, sutik = munkakornyezet_visszaallitasa_get(request, current_user)
                raise HTTPException(
                    status_code=status.HTTP_303_SEE_OTHER,
                    headers={"Location": cel, "Set-Cookie": sutik}
                )
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail="A kiválasztott munkakörnyezet cége nem fizetett elő erre a modulra!"
            )
        return current_user
    return _dependency


def get_oe_alapterulet_szamitas(company_id: int, db: Session) -> bool:
    """Cégenként kapcsolható: a tűzoltó készülékek oltóanyagegység-igényét a rendszer
    az alapterület alapján (OTSZ 16. melléklet 2. táblázata) is felajánlja-e."""
    beallitas = db.query(models.Dictionary).filter(
        models.Dictionary.company_id == company_id,
        models.Dictionary.kategoria == "OEAlapteruletSzamitas"
    ).first()
    return bool(beallitas and beallitas.ertek == "True")


def is_company_allowed(current_user, company_id: int) -> bool:
    """IDOR Védelem: Ellenőrzi, hogy a felhasználónak van-e jogosultsága az adott céghez.
    KRITIKUS JAVÍTÁS: korábban `current_user.role == "Admin"` esetén feltétel nélkül True-t
    adott - ez BÁRMELYIK ügyfélcég saját Admin-ját (nem csak a valódi rendszeradminisztrátort)
    átengedte, ami a fájlkiszolgáló route-oknál (pl. `/uploads/{company_id}/...`, ahol a
    company_id közvetlenül URL-paraméter, nem a hamisítás ellen védett working_company_id)
    lehetővé tette, hogy bármelyik cég Admin-ja egy másik cég company_id-jét kitalálva/
    megszerezve hozzáférjen annak dokumentumaihoz/fényképeihez. Mostantól ugyanazt a helyes
    logikát követi, mint az `is_company_accessible()`."""
    return is_company_accessible(current_user, company_id)


# J-25 (v76): az Emelőgép modul route-jainak szerepkör-kapui POZITÍV listák. Korábban több route csak a Felhasználót / szakértőt /
# vizsgálót tiltotta, így minden más szerepkör (Karbantartó, VIVU, EBK, Megtekintő) átment volna rajta, ha az EMELOGEP modul hozzá
# lenne rendelve - csak a modul-kiosztás védett. Három szint:
EMELOGEP_KEZELO_SZEREPKOROK = ("Admin", "Emelőgép ügyintéző")                                         # gép felvétel, státusz, AI-szkennelés
EMELOGEP_SZAKMAI_SZEREPKOROK = EMELOGEP_KEZELO_SZEREPKOROK + ("Emelőgép szakértő", "Emelőgép vizsgáló")  # gép megnyitása / szerkesztése, megfigyelés
EMELOGEP_SZEREPKOROK = EMELOGEP_SZAKMAI_SZEREPKOROK + ("Felhasználó",)                                # + a gépkezelő: napi ellenőrzés, üzemóra


def is_machine_allowed(gep, current_user):
    """IDOR Védelem: Ellenőrzi, hogy az egyedileg megnyitott géphez van-e joga a felhasználónak"""
    if not is_company_allowed(current_user, gep.company_id):
        return False

    # J-02 (v72): a hozzáférési szint (TELEPHELY / GEP) az ügyintézőre, szakértőre és vizsgálóra is érvényes - korábban
    # ők egyedi gép-eléréskor (azonosítóval) mindig átjutottak, a szint náluk csak a lista szűrője volt. Most ugyanazt a
    # szabályt követi, mint az apply_user_data_scope_filter(): kizárólag a cég-Admin mentesül. CEG szintnél (alapértelmezett)
    # semmi nem változik: a teljes cég (a számára engedélyezett cégek) gépei elérhetők.
    if current_user.role == "Admin":
        return True

    if current_user.hozzaferes_szint == "TELEPHELY":
        site_ids = []
        for s in current_user.allowed_sites:
            site_ids.append(s.id)
        return gep.site_id in site_ids

    if current_user.hozzaferes_szint == "GEP":
        machine_ids = []
        for m in current_user.allowed_machines:
            machine_ids.append(m.id)
        return gep.id in machine_ids

    return True


def get_allowed_companies_list(current_user, db: Session, modul_kod: str = None):
    """Visszaadja azokat a cégeket, amik között a felhasználó válthat a felső menüben -
    opcionálisan (modul_kod megadásával) csak azokat, amelyeknél az adott modul (pl. 'EBK')
    elő van fizetve. FONTOS: csak a valódi rendszeradminisztrátor (company_id==1) láthat
    minden céget - egy céges Admin (role=='Admin', de más company_id) NEM az, ő legfeljebb
    a saját cégét és a neki kifejezetten kiosztott (allowed_companies) cégeket láthatja."""
    if is_rendszeradminisztrator(current_user):
        cegek = db.query(models.Company).all()

    elif current_user.role in ["Admin", "Emelőgép szakértő", "Emelőgép vizsgáló", "Emelőgép ügyintéző", "Karbantartó"]:
        if len(current_user.allowed_companies) > 0:
            lista = []
            if current_user.ceg:
                lista.append(current_user.ceg)
            for allowed_c in current_user.allowed_companies:
                lista.append(allowed_c)

            # Duplikációk kiszűrése
            egyedi_cegek = {}
            for c in lista:
                if c is not None:
                    egyedi_cegek[c.id] = c

            cegek = list(egyedi_cegek.values())
        else:
            cegek = []
    else:
        cegek = []

    if modul_kod:
        cegek = [c for c in cegek if modul_kod in get_company_modul_kodok(c.id, db)]

    return cegek
