"""
CalDAV-Sync für Abwesenheiten.

Logik:
  approve  → VEVENT in persönlichem Kalender (CaldavUserConfig) +
             VEVENT in Firmenkalender (CaldavCompanyConfig)
  reject / cancel → DELETE aus beiden Kalendern

Verwendet httpx für die HTTP-Kommunikation und icalendar für iCal-Erzeugung.
Passwörter werden Fernet-verschlüsselt gespeichert (gleiche Methode wie SMTP/LDAP).
"""
from __future__ import annotations

import asyncio
import base64
import hashlib
import logging
import uuid
from datetime import date, timedelta, timezone, datetime
from typing import Union

import httpx
from icalendar import Calendar, Event
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.core.config import settings
from app.models.absence import Absence
from app.models.caldav_config import CaldavCompanyConfig, CaldavUserConfig

log = logging.getLogger(__name__)


# ── Crypto (shared with SMTP/LDAP) ────────────────────────────────────────────

def _fernet():
    # K-5: zentraler Datenschlüssel (nutzt SECRET_KEY_DATA wenn gesetzt, sonst SECRET_KEY).
    from cryptography.fernet import Fernet
    from app.core.crypto import get_fernet_key
    return Fernet(get_fernet_key())


def encrypt_password(plain: str) -> str:
    return _fernet().encrypt(plain.encode()).decode()


def decrypt_password(encrypted: str) -> str:
    return _fernet().decrypt(encrypted.encode()).decode()


# ── iCal builder ──────────────────────────────────────────────────────────────

def _build_ical(uid: str, summary: str, start: date, end: date, description: str = "") -> bytes:
    """Erzeugt einen VCALENDAR-Blob für ein ganztägiges Ereignis."""
    cal = Calendar()
    cal.add("prodid", "-//TimeMaster//DE")
    cal.add("version", "2.0")

    ev = Event()
    ev.add("uid", f"{uid}@timemaster")
    ev.add("dtstart", start)
    ev.add("dtend", end + timedelta(days=1))   # DTEND ist exklusiv
    ev.add("summary", summary)
    if description:
        ev.add("description", description)
    ev.add("status", "CONFIRMED")
    ev.add("transp", "TRANSPARENT")            # zeigt keine Verfügbarkeit als blockiert
    ev.add("dtstamp", datetime.now(timezone.utc))

    cal.add_component(ev)
    return cal.to_ical()


# ── Kalender-Titel formatieren ────────────────────────────────────────────────

def _format_summary(user: "User", absence_type: str, name_template: str) -> str:
    """
    Ersetzt Platzhalter im name_template:
      $vorname         → vollständiger Vorname
      $nachname        → vollständiger Nachname
      $vorname_short   → erster Buchstabe Vorname
      $nachname_middle → erste 3 Buchstaben Nachname
      $kuerzel         → manuell gesetztes Kürzel (Fallback: Initialen)
      $personalnummer  → Personalnummer (leer wenn nicht gesetzt)
      $typ             → Abwesenheitsart
    """
    kuerzel = user.kuerzel if user.kuerzel else (user.first_name[:1] + user.last_name[:1]).upper()
    result = name_template
    result = result.replace("$vorname_short",   user.first_name[:1])
    result = result.replace("$nachname_middle", user.last_name[:3])
    result = result.replace("$vorname",         user.first_name)
    result = result.replace("$nachname",        user.last_name)
    result = result.replace("$kuerzel",         kuerzel)
    result = result.replace("$personalnummer",  user.personnel_number or "")
    result = result.replace("$typ",             absence_type)
    return result


# ── SSRF-Schutz ───────────────────────────────────────────────────────────────

import ipaddress
import socket
from urllib.parse import urlparse


# Private/reservierte IP-Bereiche die nie als CalDAV-Server erlaubt sind
_BLOCKED_NETWORKS = [
    ipaddress.ip_network("10.0.0.0/8"),        # RFC 1918
    ipaddress.ip_network("172.16.0.0/12"),     # RFC 1918
    ipaddress.ip_network("192.168.0.0/16"),    # RFC 1918
    ipaddress.ip_network("127.0.0.0/8"),       # Loopback
    ipaddress.ip_network("169.254.0.0/16"),    # Link-local (AWS Metadata etc.)
    ipaddress.ip_network("::1/128"),           # IPv6 Loopback
    ipaddress.ip_network("fc00::/7"),          # IPv6 Unique Local
    ipaddress.ip_network("fe80::/10"),         # IPv6 Link-local
    ipaddress.ip_network("0.0.0.0/8"),         # This network
    ipaddress.ip_network("100.64.0.0/10"),     # Shared Address Space (RFC 6598)
    ipaddress.ip_network("192.0.2.0/24"),      # TEST-NET-1
    ipaddress.ip_network("198.51.100.0/24"),   # TEST-NET-2
    ipaddress.ip_network("203.0.113.0/24"),    # TEST-NET-3
    ipaddress.ip_network("240.0.0.0/4"),       # Reserved
]


async def _validate_caldav_url(url: str) -> str:
    """
    Prüft eine CalDAV-URL auf SSRF-Risiken und gibt die aufgelöste IP zurück.

    Die zurückgegebene IP wird beim eigentlichen HTTP-Request via PinnedIPTransport
    fest eingesetzt, um DNS-Rebinding zwischen Validierung und Request zu verhindern
    (TTL=0-Angriff: zweite DNS-Auflösung durch httpx könnte andere IP liefern).

    Prüfungen:
    1. Schema muss http:// oder https:// sein (kein file://, ftp://, etc.)
    2. Hostname muss vorhanden sein
    3. Keine expliziten privaten IP-Adressen im URL
    4. DNS-Auflösung + Prüfung ob die aufgelöste IP intern ist (DNS-Rebinding-Schutz)

    Gibt die erste aufgelöste (und erlaubte) IP als String zurück.
    """
    try:
        parsed = urlparse(url)
    except Exception as exc:
        raise ValueError(f"Ungültige URL: {exc}") from exc

    if parsed.scheme not in ("http", "https"):
        raise ValueError(
            f"URL-Schema '{parsed.scheme}' nicht erlaubt. Nur http:// und https:// sind gültig."
        )

    hostname = parsed.hostname
    if not hostname:
        raise ValueError("URL muss einen Hostnamen enthalten.")

    # Explizit eingetragene IP-Adressen prüfen
    try:
        ip = ipaddress.ip_address(hostname)
        _check_ip_blocked(ip, hostname)
        # hostname ist bereits eine IP-Literal → direkt zurückgeben
        return str(ip)
    except ValueError:
        pass  # Kein gültiges IP-Literal → ist ein Hostname, DNS-Auflösung folgt

    # DNS auflösen und alle aufgelösten Adressen prüfen (DNS-Rebinding-Schutz).
    # socket.getaddrinfo ist blockierend → in Executor auslagern damit der Event-Loop
    # nicht blockiert wird.
    loop = asyncio.get_event_loop()
    port = parsed.port or (443 if parsed.scheme == "https" else 80)
    try:
        infos = await loop.run_in_executor(
            None, socket.getaddrinfo, hostname, port
        )
    except socket.gaierror as exc:
        raise ValueError(f"Hostname '{hostname}' konnte nicht aufgelöst werden: {exc}") from exc

    first_allowed_ip: str | None = None
    for _family, _type, _proto, _canonname, sockaddr in infos:
        ip_str = sockaddr[0]
        try:
            ip = ipaddress.ip_address(ip_str)
            _check_ip_blocked(ip, hostname)
            if first_allowed_ip is None:
                first_allowed_ip = ip_str
        except ValueError as exc:
            raise ValueError(str(exc)) from exc

    if first_allowed_ip is None:
        raise ValueError(f"Hostname '{hostname}' konnte nicht auf eine erlaubte IP aufgelöst werden.")

    return first_allowed_ip


def _get_allowed_networks() -> list[ipaddress.IPv4Network | ipaddress.IPv6Network]:
    """Gibt die konfigurierten Whitelist-Netzwerke zurück (CALDAV_ALLOWED_CIDRS)."""
    from app.core.config import settings
    networks = []
    for cidr in settings.caldav_allowed_cidrs:
        cidr = cidr.strip()
        if not cidr:
            continue
        try:
            networks.append(ipaddress.ip_network(cidr, strict=False))
        except ValueError:
            log.warning("Ungültiger CIDR in CALDAV_ALLOWED_CIDRS ignoriert: %r", cidr)
    return networks


def _check_ip_blocked(ip: ipaddress.IPv4Address | ipaddress.IPv6Address, hostname: str) -> None:
    """Wirft ValueError wenn die IP in einem blockierten Netz liegt.

    Whitelist (CALDAV_ALLOWED_CIDRS) schlägt die Blockliste –
    damit können interne Nextcloud-Server im LAN trotzdem genutzt werden.
    """
    # Whitelist-Check zuerst: explizit erlaubte CIDRs überschreiben die Blockliste
    for allowed in _get_allowed_networks():
        if ip in allowed:
            return  # explizit erlaubt → kein Fehler

    for network in _BLOCKED_NETWORKS:
        if ip in network:
            raise ValueError(
                f"Host '{hostname}' ({ip}) liegt in einem privaten/reservierten "
                f"Adressbereich ({network}) und darf nicht als CalDAV-Server genutzt werden. "
                f"Tipp: Interne Server können per CALDAV_ALLOWED_CIDRS freigegeben werden."
            )


# ── DNS-Rebinding-sicherer Transport ──────────────────────────────────────────

class PinnedIPTransport(httpx.AsyncHTTPTransport):
    """
    Sicherheits-Transport: Nutzt eine vorab aufgelöste IP statt erneuter DNS-Auflösung.
    Verhindert DNS-Rebinding zwischen URL-Validierung und eigentlichem HTTP-Request
    (Angriff mit TTL=0: zweite DNS-Auflösung durch httpx könnte andere IP liefern).

    Der originale Host-Header bleibt erhalten, damit TLS-SNI und
    vHost-Routing des Servers korrekt funktionieren.
    """

    def __init__(self, pinned_ip: str, **kwargs):
        super().__init__(**kwargs)
        self._pinned_ip = pinned_ip

    async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
        # URL mit aufgelöster IP statt Hostname umschreiben
        new_url = request.url.copy_with(host=self._pinned_ip)
        # Neues Request-Objekt mit ersetzter URL; alle Header (inkl. Host) bleiben
        # erhalten – httpx setzt Host automatisch auf den original-Hostnamen wenn
        # er nicht explizit überschrieben wird, was SNI und vHost korrekt hält.
        request = request.__class__(
            method=request.method,
            url=new_url,
            headers=request.headers,
            stream=request.stream,
            extensions=request.extensions,
        )
        return await super().handle_async_request(request)


# ── HTTP helpers ───────────────────────────────────────────────────────────────

def _event_url(calendar_url: str, uid: str) -> str:
    return calendar_url.rstrip("/") + f"/{uid}.ics"


def _effective_verify_ssl(verify_ssl: bool) -> bool:
    """
    Gibt den tatsächlich zu verwendenden verify_ssl-Wert zurück.
    In Production ist SSL-Verifikation immer aktiviert – verify_ssl=False wird ignoriert.
    """
    if settings.is_production and not verify_ssl:
        log.warning(
            "CalDAV: verify_ssl=False in Production ignoriert – SSL-Verifikation wird erzwungen. "
            "Für selbstsignierte Zertifikate das CA-Bundle unter REQUESTS_CA_BUNDLE konfigurieren."
        )
        return True
    return verify_ssl


async def _http_put(
    calendar_url: str, username: str, password: str, uid: str,
    ical: bytes, verify_ssl: bool,
) -> str:
    """PUT event. Returns ETag (empty string if server doesn't send one)."""
    pinned_ip = await _validate_caldav_url(calendar_url)
    verify_ssl = _effective_verify_ssl(verify_ssl)
    url = _event_url(calendar_url, uid)
    transport = PinnedIPTransport(pinned_ip=pinned_ip, verify=verify_ssl)
    async with httpx.AsyncClient(transport=transport, verify=verify_ssl, timeout=15) as client:
        resp = await client.put(
            url, content=ical,
            headers={"Content-Type": "text/calendar; charset=utf-8"},
            auth=(username, password),
        )
        resp.raise_for_status()
        return resp.headers.get("ETag", "")


async def _http_delete(
    calendar_url: str, username: str, password: str, uid: str, verify_ssl: bool,
) -> None:
    pinned_ip = await _validate_caldav_url(calendar_url)
    verify_ssl = _effective_verify_ssl(verify_ssl)
    url = _event_url(calendar_url, uid)
    transport = PinnedIPTransport(pinned_ip=pinned_ip, verify=verify_ssl)
    async with httpx.AsyncClient(transport=transport, verify=verify_ssl, timeout=15) as client:
        resp = await client.delete(url, auth=(username, password))
        if resp.status_code not in (200, 204, 404):
            resp.raise_for_status()


async def _http_propfind(
    calendar_url: str, username: str, password: str, verify_ssl: bool,
) -> int:
    """Einfacher Verbindungstest via PROPFIND Depth:0. Gibt HTTP-Status zurück."""
    pinned_ip = await _validate_caldav_url(calendar_url)
    verify_ssl = _effective_verify_ssl(verify_ssl)
    body = b'<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>'
    transport = PinnedIPTransport(pinned_ip=pinned_ip, verify=verify_ssl)
    async with httpx.AsyncClient(transport=transport, verify=verify_ssl, timeout=10) as client:
        resp = await client.request(
            "PROPFIND", calendar_url.rstrip("/") + "/",
            content=body,
            headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "0"},
            auth=(username, password),
        )
        return resp.status_code


# ── Service ───────────────────────────────────────────────────────────────────

class CalDavService:

    # ── Config laden ──────────────────────────────────────────────────────────

    async def get_company_config(
        self, company_id: uuid.UUID, db: AsyncSession
    ) -> CaldavCompanyConfig | None:
        return await db.scalar(
            select(CaldavCompanyConfig).where(CaldavCompanyConfig.company_id == company_id)
        )

    async def get_user_config(
        self, user_id: uuid.UUID, db: AsyncSession
    ) -> CaldavUserConfig | None:
        return await db.scalar(
            select(CaldavUserConfig).where(CaldavUserConfig.user_id == user_id)
        )

    # ── Hintergrund-Sync (eigene Session) ─────────────────────────────────────
    # Fire-and-forget aus Request-Handlern darf NICHT die Request-Session
    # weiterverwenden (wird nach der Response geschlossen; in Tests sogar
    # sessionweit geteilt → "another operation in progress"). Diese Wrapper
    # öffnen eine eigene Session, laden die Abwesenheit frisch und committen.

    async def sync_approved_bg(self, absence_id: uuid.UUID) -> None:
        await self._run_bg(absence_id, self.sync_approved)

    async def sync_removed_bg(self, absence_id: uuid.UUID) -> None:
        await self._run_bg(absence_id, self.sync_removed)

    async def _run_bg(self, absence_id: uuid.UUID, fn) -> None:
        from sqlalchemy import text
        from app.core.database import AsyncSessionLocal

        try:
            async with AsyncSessionLocal() as db:
                # Interner Job ohne Tenant-Kontext → RLS-Bypass nötig
                await db.execute(text("SET LOCAL app.bypass_rls = 'on'"))
                absence = await db.get(Absence, absence_id)
                if absence is None:
                    return
                await fn(absence, db)
                await db.commit()
        except Exception as exc:  # darf den Request niemals beeinflussen
            log.warning("CalDAV background sync failed for absence %s: %s", absence_id, exc)

    # ── Sync-Operationen ──────────────────────────────────────────────────────

    async def sync_approved(self, absence: Absence, db: AsyncSession) -> None:
        """Wird nach Genehmigung gerufen: Event in beide Kalender einpflegen."""
        # User und AbsenceType laden (für VEVENT-Titel)
        from app.models.user import User
        from app.models.absence_type import AbsenceType

        user = await db.get(User, absence.user_id)
        atype = await db.get(AbsenceType, absence.type_id)
        if not user or not atype:
            return

        if absence.caldav_uid is None:
            absence.caldav_uid = str(uuid.uuid4())

        description = absence.note or ""

        # Persönlicher Kalender: nur Abwesenheitsart, kein Name
        personal_ical = _build_ical(
            absence.caldav_uid, atype.name,
            absence.start_date, absence.end_date, description,
        )
        user_cfg = await self.get_user_config(user.id, db)
        if user_cfg and user_cfg.enabled and user_cfg.calendar_url:
            try:
                pw = decrypt_password(user_cfg.password_encrypted)
                etag = await _http_put(
                    user_cfg.calendar_url, user_cfg.username, pw,
                    absence.caldav_uid, personal_ical, user_cfg.verify_ssl,
                )
                absence.caldav_user_etag = etag
            except Exception as exc:
                absence.caldav_last_error = f"User-Kalender: {exc}"
                log.warning("CalDAV user sync failed for absence %s: %s", absence.id, exc)

        # Firmenkalender: Titelformat per Konfiguration
        company_cfg = await self.get_company_config(user.company_id, db)
        if company_cfg and company_cfg.enabled and company_cfg.calendar_url:
            company_summary = _format_summary(user, atype.name, company_cfg.name_template)
            company_ical = _build_ical(
                absence.caldav_uid, company_summary,
                absence.start_date, absence.end_date, description,
            )
            try:
                pw = decrypt_password(company_cfg.password_encrypted)
                etag = await _http_put(
                    company_cfg.calendar_url, company_cfg.username, pw,
                    absence.caldav_uid, company_ical, company_cfg.verify_ssl,
                )
                absence.caldav_company_etag = etag
            except Exception as exc:
                err = f"Firmen-Kalender: {exc}"
                absence.caldav_last_error = (
                    (absence.caldav_last_error + " | " + err) if absence.caldav_last_error else err
                )
                log.warning("CalDAV company sync failed for absence %s: %s", absence.id, exc)

        absence.caldav_synced_at = datetime.now(timezone.utc)

    async def sync_removed(self, absence: Absence, db: AsyncSession) -> None:
        """Wird nach Ablehnung/Stornierung gerufen: Event aus Kalendern löschen."""
        if not absence.caldav_uid:
            return

        from app.models.user import User
        user = await db.get(User, absence.user_id)
        if not user:
            return

        # Persönlicher Kalender
        user_cfg = await self.get_user_config(user.id, db)
        if user_cfg and user_cfg.enabled and user_cfg.calendar_url:
            try:
                pw = decrypt_password(user_cfg.password_encrypted)
                await _http_delete(
                    user_cfg.calendar_url, user_cfg.username, pw,
                    absence.caldav_uid, user_cfg.verify_ssl,
                )
                absence.caldav_user_etag = None
            except Exception as exc:
                log.warning("CalDAV user delete failed for absence %s: %s", absence.id, exc)

        # Firmenkalender
        company_cfg = await self.get_company_config(user.company_id, db)
        if company_cfg and company_cfg.enabled and company_cfg.calendar_url:
            try:
                pw = decrypt_password(company_cfg.password_encrypted)
                await _http_delete(
                    company_cfg.calendar_url, company_cfg.username, pw,
                    absence.caldav_uid, company_cfg.verify_ssl,
                )
                absence.caldav_company_etag = None
            except Exception as exc:
                log.warning("CalDAV company delete failed for absence %s: %s", absence.id, exc)

        absence.caldav_last_error = None
        absence.caldav_synced_at = datetime.now(timezone.utc)

    async def resync_all_approved(self, company_id: uuid.UUID, db: AsyncSession) -> dict:
        """Alle genehmigten Abwesenheiten der Firma neu synchronisieren."""
        from app.models.absence import AbsenceStatus
        from app.models.user import User
        result = await db.scalars(
            select(Absence)
            .join(Absence.user)
            .where(
                Absence.status == AbsenceStatus.APPROVED,
                User.company_id == company_id,
            )
        )
        absences = list(result.all())

        ok = 0
        failed = 0
        for absence in absences:
            try:
                await self.sync_approved(absence, db)
                ok += 1
            except Exception as exc:
                failed += 1
                log.error("Resync failed for absence %s: %s", absence.id, exc)

        return {"synced": ok, "failed": failed, "total": len(absences)}

    # ── Verbindungstest ───────────────────────────────────────────────────────

    async def test_config(
        self, cfg: Union[CaldavCompanyConfig, CaldavUserConfig]
    ) -> dict:
        if not cfg.calendar_url:
            return {"ok": False, "error": "Keine Kalender-URL konfiguriert."}
        try:
            pw = decrypt_password(cfg.password_encrypted)
            status = await _http_propfind(cfg.calendar_url, cfg.username, pw, cfg.verify_ssl)
            if status in (200, 207):
                return {"ok": True, "status": status}
            return {"ok": False, "error": f"Server antwortete mit HTTP {status}"}
        except Exception as exc:
            return {"ok": False, "error": str(exc)}


caldav_service = CalDavService()
