#!/usr/bin/env python3
"""
VENTT Export Agent
==================
Logt in op het Airios Cloud portal (manage.connect2myhome.eu), detecteert
automatisch alle bridges/devices die aan het account hangen, start voor elke
bridge een export-job, wacht tot deze klaar is, downloadt de ZIP, pakt de
CSV's uit en normaliseert ze (kolomnamen + kapotte getalnotatie).

Gebruik:
    python3 ventt_export_agent.py

Configuratie via .env bestand in dezelfde map (zie .env.example).

Output structuur:
    data/
      raw/        -> ongewijzigde gedownloade CSV's (audit trail)
      processed/  -> opgeschoonde CSV's met leesbare kolomnamen
      logs/       -> 1 logbestand per dag
"""

from __future__ import annotations

import csv
import html
import json
import logging
import os
import re
import subprocess
import sys
import time
import zipfile
from datetime import datetime, timezone
from pathlib import Path

import requests
from bs4 import BeautifulSoup

# --------------------------------------------------------------------------
# Configuratie
# --------------------------------------------------------------------------

BASE_URL = "https://manage.connect2myhome.eu"
LOGIN_URL = f"{BASE_URL}/login"

# Tijdrange voor de export. Waarden zoals in de <select name="timeRangeEnd">:
# 1=24u, 7=7 dagen, 30=30 dagen, 120, 180, 365, 0=geen limiet
TIME_RANGE_END = "7"

# Hoe lang max wachten tot een export-job klaar is (seconden)
POLL_TIMEOUT_SECONDS = 240
POLL_INTERVAL_SECONDS = 2.5

SCRIPT_DIR = Path(__file__).resolve().parent
DATA_DIR = SCRIPT_DIR / "data"
RAW_DIR = DATA_DIR / "raw"
PROCESSED_DIR = DATA_DIR / "processed"
LOG_DIR = DATA_DIR / "logs"
DASHBOARD_TEMPLATE_PATH = SCRIPT_DIR / "dashboard_template" / "dashboard_template.html"

# Kolom-mapping: register-nummer (Modbus-achtige code) -> leesbare naam
COLUMN_MAPPING = {
    "Time": "Time",
    "40103": "Fault Status",
    "41000": "Ventilation Mode",
    "41001": "Exhaust Fan PWM",
    "41002": "Flow Setpoint",
    "41004": "Flow Inlet Supply",
    "41006": "Flow Exhaust (Supply if flow negative for Supply)",
    "41008": "Ventilation RPM",
    "41010": "Flow Control Local",
    "41012": "Flow Control Additional",
    "41014": "Flow Max",
    "41016": "Indoor Temperature",
    "41018": "Outdoor Temperature",
    "41020": "Humidity",
    "41021": "Sensor Demand",
    "41022": "CO2 Level",
    "41023": "Valve Position",
    "41027": "Supply fan PWM (Exhaust Fan version 1.0.0 or lower)",
}


# --------------------------------------------------------------------------
# Logging
# --------------------------------------------------------------------------

def setup_logging() -> logging.Logger:
    LOG_DIR.mkdir(parents=True, exist_ok=True)
    log_file = LOG_DIR / f"{datetime.now():%Y-%m-%d}.log"

    logger = logging.getLogger("ventt_agent")
    logger.setLevel(logging.INFO)
    logger.handlers.clear()

    fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", "%Y-%m-%d %H:%M:%S")

    fh = logging.FileHandler(log_file, encoding="utf-8")
    fh.setFormatter(fmt)
    logger.addHandler(fh)

    sh = logging.StreamHandler(sys.stdout)
    sh.setFormatter(fmt)
    logger.addHandler(sh)

    return logger


# --------------------------------------------------------------------------
# Helper: kapotte getalnotatie fixen
# --------------------------------------------------------------------------
#
# Het portal exporteert sommige floats met een floating-point afrondingsfout
# die vervolgens fout wordt geserialiseerd: i.p.v. "27.08" exporteert het
# soms "27.079.998" (oorspronkelijk waarschijnlijk 27.07999800000001).
# Regel: alleen de EERSTE punt is de echte decimaalscheiding, alle volgende
# punten zijn ruis en worden verwijderd.

NUMBER_WITH_EXTRA_DOTS = re.compile(r"^-?\d+(\.\d+){2,}$")


def fix_broken_number(value: str) -> str:
    value = value.strip()
    if not value:
        return value
    if not NUMBER_WITH_EXTRA_DOTS.match(value):
        return value
    sign = ""
    if value.startswith("-"):
        sign, value = "-", value[1:]
    first, rest = value.split(".", 1)
    rest = rest.replace(".", "")
    return f"{sign}{first}.{rest}"


# --------------------------------------------------------------------------
# Portal client
# --------------------------------------------------------------------------

class PortalError(RuntimeError):
    pass


class VenttPortalClient:
    def __init__(self, username: str, password: str, logger: logging.Logger,
                 user_details_url: str | None = None):
        self.username = username
        self.password = password
        self.logger = logger
        self.user_details_url = user_details_url
        self.session = requests.Session()
        self.session.headers.update({
            "User-Agent": "VENTT-Export-Agent/1.0 (+robbert@ventt.nl)"
        })

    # ---- login -----------------------------------------------------

    def login(self) -> None:
        self.logger.info("Inloggen op portal...")
        resp = self.session.get(LOGIN_URL, timeout=30)
        resp.raise_for_status()

        soup = BeautifulSoup(resp.text, "html.parser")
        csrf_input = soup.find("input", {"name": "_csrf"})
        if not csrf_input or not csrf_input.get("value"):
            raise PortalError("Kon geen _csrf token vinden op de loginpagina.")
        csrf_token = csrf_input["value"]

        payload = {
            "_csrf": csrf_token,
            "username": self.username,
            "password": self.password,
        }
        resp = self.session.post(LOGIN_URL, data=payload, timeout=30)
        resp.raise_for_status()

        if "/login" in resp.url:
            raise PortalError(
                "Login lijkt mislukt (we zijn terug op de loginpagina). "
                "Controleer gebruikersnaam/wachtwoord in .env."
            )
        self.logger.info("Login gelukt.")

    # ---- bridges/devices detecteren ---------------------------------

    def get_user_details_url(self) -> str:
        """Geeft de URL van de user-detailpagina terug.

        Gebruikt bij voorkeur VENTT_USER_DETAILS_URL uit .env (robuust,
        onafhankelijk van eventuele wijzigingen in de portal-navigatie).
        Als die niet gezet is, wordt geprobeerd de link automatisch te
        vinden op de hoofdpagina (kan breken als de layout afwijkt)."""
        if self.user_details_url:
            return self.user_details_url

        resp = self.session.get(f"{BASE_URL}/", timeout=30)
        resp.raise_for_status()
        soup = BeautifulSoup(resp.text, "html.parser")

        link = soup.find("a", href=re.compile(r"/user/appuser/details/"))
        if link:
            return BASE_URL + link["href"]

        raise PortalError(
            "Kon geen link naar de user-detailpagina vinden op de hoofdpagina, "
            "en VENTT_USER_DETAILS_URL is niet gezet in .env. "
            "Zet VENTT_USER_DETAILS_URL=<volledige URL> in .env als vaste oplossing."
        )

    def discover_bridges(self) -> list[str]:
        """Geeft een lijst van bridge-ids terug, gevonden onder 'Active Bridges'."""
        user_url = self.get_user_details_url()
        self.logger.info(f"Bridges ophalen via {user_url}")
        resp = self.session.get(user_url, timeout=30)
        resp.raise_for_status()
        soup = BeautifulSoup(resp.text, "html.parser")

        bridge_ids: list[str] = []
        for a in soup.find_all("a", href=re.compile(r"/device/bridge/details/")):
            href = a["href"]
            match = re.search(r"/device/bridge/details/([a-f0-9-]+)", href)
            if match:
                bridge_id = match.group(1)
                if bridge_id not in bridge_ids:
                    bridge_ids.append(bridge_id)

        self.logger.info(f"{len(bridge_ids)} bridge(s) gevonden: {bridge_ids}")
        return bridge_ids

    # ---- export per bridge ------------------------------------------

    def get_bridge_export_form(self, bridge_id: str) -> tuple[str, list[str]]:
        """Haalt csrf-token en device-ids op uit de export-modal van een
        bridge-detailpagina."""
        url = f"{BASE_URL}/device/bridge/details/{bridge_id}"
        resp = self.session.get(url, timeout=30)
        resp.raise_for_status()
        soup = BeautifulSoup(resp.text, "html.parser")

        modal = soup.find("div", {"id": "modal-export-bridge"})
        if not modal:
            raise PortalError(f"Geen export-modal gevonden voor bridge {bridge_id}.")

        csrf_input = modal.find("input", {"name": "_csrf"})
        if not csrf_input or not csrf_input.get("value"):
            raise PortalError(f"Geen csrf-token gevonden in export-modal voor bridge {bridge_id}.")
        csrf_token = csrf_input["value"]

        device_ids = [
            chk["value"]
            for chk in modal.find_all("input", {"name": "selectedDevices"})
            if chk.get("value")
        ]
        if not device_ids:
            raise PortalError(f"Geen devices gevonden in export-modal voor bridge {bridge_id}.")

        return csrf_token, device_ids

    def start_export(self, bridge_id: str) -> str:
        """Start een export-job voor een bridge en geeft de job-id terug."""
        csrf_token, device_ids = self.get_bridge_export_form(bridge_id)
        self.logger.info(f"Export starten voor bridge {bridge_id} ({len(device_ids)} device(s))...")

        payload = [
            ("_csrf", csrf_token),
            ("bridge", bridge_id),
            ("timeRangeEnd", TIME_RANGE_END),
        ]
        for device_id in device_ids:
            payload.append(("selectedDevices", device_id))

        resp = self.session.post(
            f"{BASE_URL}/export/bridge",
            data=payload,
            timeout=30,
        )
        resp.raise_for_status()

        match = re.search(r"/export/status/([a-f0-9-]+)", resp.url)
        if not match:
            raise PortalError(
                f"Kon geen job-id uit de redirect-URL halen na export-start "
                f"voor bridge {bridge_id}. URL was: {resp.url}"
            )
        job_id = match.group(1)
        self.logger.info(f"Export-job gestart: {job_id}")
        return job_id

    def wait_for_export(self, job_id: str) -> dict:
        """Pollt de status-endpoint tot de job klaar is (of faalt/timeout)."""
        status_url = f"{BASE_URL}/export/status/{job_id}/json"
        deadline = time.monotonic() + POLL_TIMEOUT_SECONDS

        while time.monotonic() < deadline:
            resp = self.session.get(status_url, timeout=30)
            resp.raise_for_status()
            data = resp.json()
            status = data.get("status")
            self.logger.info(f"Job {job_id} status: {status}")

            if status == "DONE":
                return data
            if status in ("TIMEOUT", "ERROR"):
                raise PortalError(f"Export-job {job_id} is mislukt met status {status}.")

            time.sleep(POLL_INTERVAL_SECONDS)

        raise PortalError(f"Export-job {job_id} duurde te lang (> {POLL_TIMEOUT_SECONDS}s).")

    def download_export(self, job_id: str, target_path: Path) -> None:
        url = f"{BASE_URL}/export/status/{job_id}/download"
        self.logger.info(f"ZIP downloaden van job {job_id} naar {target_path}...")
        resp = self.session.get(url, timeout=120, stream=True)
        resp.raise_for_status()

        target_path.parent.mkdir(parents=True, exist_ok=True)
        with open(target_path, "wb") as f:
            for chunk in resp.iter_content(chunk_size=8192):
                f.write(chunk)
        self.logger.info(f"ZIP opgeslagen: {target_path} ({target_path.stat().st_size} bytes)")


# --------------------------------------------------------------------------
# CSV-verwerking
# --------------------------------------------------------------------------

def detect_delimiter(sample_line: str) -> str:
    """Bepaalt of een CSV-regel komma- of puntkomma-gescheiden is.
    De portal-export gebruikt blijkbaar niet altijd hetzelfde scheidingsteken
    (afhankelijk van export-instelling/locale), dus dit wordt per bestand
    automatisch gedetecteerd in plaats van hardcoded aangenomen."""
    comma_count = sample_line.count(",")
    semicolon_count = sample_line.count(";")
    return ";" if semicolon_count >= comma_count else ","


def process_csv(raw_path: Path, processed_path: Path, logger: logging.Logger) -> None:
    """Leest een ruwe export-CSV, mapt kolomnamen en fixt kapotte getallen.
    Schrijft de output altijd puntkomma-gescheiden, ongeacht het
    scheidingsteken van de bron, zodat downstream-verwerking (dashboard)
    een consistent formaat kan aannemen."""
    with open(raw_path, encoding="utf-8-sig", newline="") as f_in:
        first_line = f_in.readline()
        if not first_line:
            logger.warning(f"Lege CSV overgeslagen: {raw_path}")
            return
        input_delimiter = detect_delimiter(first_line)
        f_in.seek(0)

        reader = csv.reader(f_in, delimiter=input_delimiter)
        try:
            header = next(reader)
        except StopIteration:
            logger.warning(f"Lege CSV overgeslagen: {raw_path}")
            return

        new_header = [COLUMN_MAPPING.get(col.strip(), col.strip()) for col in header]

        processed_path.parent.mkdir(parents=True, exist_ok=True)
        with open(processed_path, "w", encoding="utf-8", newline="") as f_out:
            writer = csv.writer(f_out, delimiter=";")
            writer.writerow(new_header)

            row_count = 0
            for row in reader:
                fixed_row = [fix_broken_number(cell) for cell in row]
                writer.writerow(fixed_row)
                row_count += 1

    logger.info(
        f"Verwerkt: {raw_path.name} -> {processed_path.name} "
        f"({row_count} rijen, bron-scheidingsteken: '{input_delimiter}')"
    )


def extract_and_process_zip(zip_path: Path, bridge_id: str, logger: logging.Logger) -> list[Path]:
    """Pakt een gedownloade ZIP uit en verwerkt alle CSV's erin.
    Geeft de lijst van processed-bestandspaden terug."""
    today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    raw_extract_dir = RAW_DIR / today / bridge_id
    raw_extract_dir.mkdir(parents=True, exist_ok=True)

    processed_paths: list[Path] = []

    with zipfile.ZipFile(zip_path) as zf:
        for name in zf.namelist():
            if not name.lower().endswith(".csv"):
                continue
            zf.extract(name, raw_extract_dir)
            raw_csv_path = raw_extract_dir / name

            # device-id proberen te herleiden uit de bestandsnaam voor een
            # nette output-naam; val terug op de originele naam.
            device_match = re.search(r"([a-f0-9]{32,})", name)
            device_id = device_match.group(1) if device_match else Path(name).stem

            processed_path = PROCESSED_DIR / today / f"{device_id}.csv"
            process_csv(raw_csv_path, processed_path, logger)
            processed_paths.append(processed_path)

    return processed_paths


# --------------------------------------------------------------------------
# Dashboard HTML genereren
# --------------------------------------------------------------------------

def build_device_label(device_id: str, row_count: int) -> str:
    short_id = device_id[:8]
    return f"Unit {short_id} - {row_count} metingen"


def load_device_rows(csv_path: Path) -> list[dict]:
    with open(csv_path, encoding="utf-8", newline="") as f:
        reader = csv.DictReader(f, delimiter=";")
        return list(reader)


TIME_FORMATS = ("%d/%m/%Y %H:%M:%S", "%d/%m/%Y %H:%M")


def parse_time_to_epoch_seconds(time_str: str) -> int | None:
    """Zet 'DD/MM/YYYY HH:MM[:SS]' om naar epoch-seconden (UTC), zoals de
    portal-export aangeeft (export is in UTC volgens de exportmodal).
    De export bevat soms wel en soms geen secondencomponent; beide
    varianten worden geprobeerd."""
    cleaned = time_str.strip()
    for fmt in TIME_FORMATS:
        try:
            dt = datetime.strptime(cleaned, fmt)
            return int(dt.replace(tzinfo=timezone.utc).timestamp())
        except ValueError:
            continue
    return None


def rows_to_columnar(rows: list[dict]) -> dict:
    """Zet rij-georiënteerde CSV-data om naar een compacte kolom-
    georiënteerde structuur: { kolomnaam: [[epoch_seconds, waarde], ...] }.
    Lege cellen worden overgeslagen (de bron-data is al sparse/event-based).
    Niet-numerieke waarden (zoals 'Fault Status'/'Ventilation Mode' codes
    of tekstuele statussen) blijven als string bewaard."""
    if not rows:
        return {}

    columns = [c for c in rows[0].keys() if c != "Time"]
    columnar: dict[str, list] = {}

    for col in columns:
        series = []
        for row in rows:
            value = row.get(col, "")
            if not value or not value.strip():
                continue
            t_epoch = parse_time_to_epoch_seconds(row.get("Time", ""))
            if t_epoch is None:
                continue
            try:
                parsed_value = float(value)
            except ValueError:
                parsed_value = value
            series.append([t_epoch, parsed_value])
        if series:
            columnar[col] = series

    return columnar


def generate_dashboard(date_str: str, logger: logging.Logger) -> Path | None:
    """Verzamelt alle processed CSV's van een dag, zet ze om naar een
    compacte kolom-georiënteerde JSON-structuur en injecteert dit in het
    HTML-dashboard-template. Schrijft het resultaat naar
    data/processed/{date}/dashboard.html."""
    processed_day_dir = PROCESSED_DIR / date_str
    if not processed_day_dir.exists():
        logger.warning(f"Geen processed-map gevonden voor {date_str}, dashboard niet gegenereerd.")
        return None

    csv_files = sorted(processed_day_dir.glob("*.csv"))
    if not csv_files:
        logger.warning(f"Geen CSV's gevonden in {processed_day_dir}, dashboard niet gegenereerd.")
        return None

    if not DASHBOARD_TEMPLATE_PATH.exists():
        logger.error(f"Dashboard-template niet gevonden op {DASHBOARD_TEMPLATE_PATH}.")
        return None

    devices = []
    for csv_path in csv_files:
        device_id = csv_path.stem
        try:
            rows = load_device_rows(csv_path)
        except Exception as e:  # noqa: BLE001
            logger.error(f"Kon {csv_path} niet inlezen voor dashboard: {e}")
            continue

        if not rows:
            continue

        columnar = rows_to_columnar(rows)
        if not columnar:
            sample_time = rows[0].get("Time", "") if rows else ""
            logger.warning(
                f"Geen bruikbare data in {csv_path.name} ({len(rows)} rijen ingelezen, "
                f"0 kolommen na verwerking). Voorbeeld tijdwaarde: {sample_time!r}. "
                f"Mogelijk kon het tijdformaat niet geparsed worden."
            )
            continue

        devices.append({
            "deviceId": device_id,
            "label": build_device_label(device_id, len(rows)),
            "columns": columnar,
        })

    if not devices:
        logger.warning("Geen geldige device-data om in het dashboard te tonen.")
        return None

    dashboard_data = {
        "generatedAt": datetime.now().strftime("%Y-%m-%d %H:%M"),
        "devices": devices,
    }

    template_html = DASHBOARD_TEMPLATE_PATH.read_text(encoding="utf-8")
    data_json = json.dumps(dashboard_data, ensure_ascii=False, separators=(",", ":"))
    # </script> binnen de JSON-payload zou de injectie kunnen breken; ontsnap dit defensief.
    data_json_safe = data_json.replace("</script>", "<\\/script>")

    output_html = template_html.replace(
        "/*__DATA_PLACEHOLDER__*/ {}",
        data_json_safe,
    )

    output_path = processed_day_dir / "dashboard.html"
    output_path.write_text(output_html, encoding="utf-8")
    size_kb = output_path.stat().st_size / 1024
    logger.info(
        f"Dashboard gegenereerd: {output_path} "
        f"({len(devices)} unit(s), {size_kb:.0f} KB)"
    )
    return output_path


# --------------------------------------------------------------------------
# Upload naar server (scp)
# --------------------------------------------------------------------------

def run_scp(local_path: Path, remote_spec: str, logger: logging.Logger,
            recursive: bool = False) -> bool:
    """Voert een scp-commando uit. Geeft True/False terug i.p.v. te raisen,
    zodat de aanroeper per bestand/map kan beslissen hoe een fout af te
    handelen (de upload mag de rest van de run niet blokkeren)."""
    cmd = ["scp", "-q"]
    if recursive:
        cmd.append("-r")
    cmd.extend([str(local_path), remote_spec])

    logger.info(f"scp: {local_path} -> {remote_spec}")
    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            text=True,
            timeout=180,
        )
    except subprocess.TimeoutExpired:
        logger.error(f"scp timeout bij uploaden van {local_path}.")
        return False
    except FileNotFoundError:
        logger.error("Het commando 'scp' is niet gevonden op dit systeem.")
        return False

    if result.returncode != 0:
        logger.error(
            f"scp mislukt voor {local_path} (exit code {result.returncode}): "
            f"{result.stderr.strip()}"
        )
        return False
    return True


def upload_day_to_server(date_str: str, scp_host: str, scp_root_dir: str,
                          dashboard_path: Path | None, logger: logging.Logger) -> None:
    """Upload de volledige dagmap naar de server, met submappen raw/ en
    processed/ behouden, en het dashboard apart als index.html in de
    root van die dagmap.

    Resulterende structuur op de server:
        {scp_root_dir}/{date_str}/index.html
        {scp_root_dir}/{date_str}/raw/...
        {scp_root_dir}/{date_str}/processed/...
    """
    remote_day_dir = f"{scp_root_dir.rstrip('/')}/{date_str}"

    # Eerst de doelmap (en subpaden) aanmaken op de server via ssh, anders
    # faalt scp -r simpelweg als de parent-map nog niet bestaat.
    mkdir_cmd = ["ssh", scp_host, f"mkdir -p {remote_day_dir}/raw {remote_day_dir}/processed"]
    try:
        mkdir_result = subprocess.run(mkdir_cmd, capture_output=True, text=True, timeout=60)
        if mkdir_result.returncode != 0:
            logger.error(
                f"Kon doelmap niet aanmaken op {scp_host}: {mkdir_result.stderr.strip()}"
            )
            return
    except subprocess.TimeoutExpired:
        logger.error(f"ssh mkdir timeout op {scp_host}.")
        return
    except FileNotFoundError:
        logger.error("Het commando 'ssh' is niet gevonden op dit systeem.")
        return

    any_failure = False

    raw_day_dir = RAW_DIR / date_str
    if raw_day_dir.exists():
        for item in raw_day_dir.iterdir():
            if item.name.startswith("."):
                continue  # .DS_Store en andere verborgen bestanden overslaan
            ok = run_scp(item, f"{scp_host}:{remote_day_dir}/raw/", logger, recursive=item.is_dir())
            any_failure = any_failure or not ok

    processed_day_dir = PROCESSED_DIR / date_str
    if processed_day_dir.exists():
        for item in processed_day_dir.iterdir():
            if item.name.startswith("."):
                continue  # .DS_Store en andere verborgen bestanden overslaan
            if item.name == "dashboard.html":
                continue  # apart geupload als index.html, zie onder
            ok = run_scp(item, f"{scp_host}:{remote_day_dir}/processed/", logger, recursive=item.is_dir())
            any_failure = any_failure or not ok

    if dashboard_path and dashboard_path.exists():
        ok = run_scp(dashboard_path, f"{scp_host}:{remote_day_dir}/index.html", logger)
        any_failure = any_failure or not ok
    else:
        logger.warning("Geen dashboard.html gevonden om als index.html te uploaden.")

    # scp neemt soms strikte lokale bestandsrechten over (bv. rw-------),
    # waardoor de webserver het bestand niet kan lezen ("Forbidden"). Zet
    # daarom expliciet leesbare rechten op alles in de dagmap.
    chmod_cmd = ["ssh", scp_host, f"chmod -R u+rwX,go+rX {remote_day_dir}"]
    try:
        chmod_result = subprocess.run(chmod_cmd, capture_output=True, text=True, timeout=60)
        if chmod_result.returncode != 0:
            logger.warning(
                f"chmod op {scp_host}:{remote_day_dir} gaf een fout: "
                f"{chmod_result.stderr.strip()}"
            )
    except (subprocess.TimeoutExpired, FileNotFoundError) as e:
        logger.warning(f"Kon chmod niet uitvoeren op {scp_host}: {e}")

    if any_failure:
        logger.warning(f"Upload naar {scp_host}:{remote_day_dir} deels mislukt, zie hierboven.")
    else:
        logger.info(f"Upload naar {scp_host}:{remote_day_dir} voltooid.")


# --------------------------------------------------------------------------
# Main
# --------------------------------------------------------------------------

def load_env() -> tuple[str, str, str | None, str | None, str | None]:
    env_path = SCRIPT_DIR / ".env"
    if not env_path.exists():
        raise PortalError(
            f".env bestand niet gevonden op {env_path}. "
            f"Kopieer .env.example naar .env en vul je gegevens in."
        )

    values: dict[str, str] = {}
    for line in env_path.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, _, val = line.partition("=")
        values[key.strip()] = val.strip().strip('"').strip("'")

    username = values.get("VENTT_USERNAME")
    password = values.get("VENTT_PASSWORD")
    if not username or not password:
        raise PortalError("VENTT_USERNAME en/of VENTT_PASSWORD ontbreken in .env.")

    user_details_url = values.get("VENTT_USER_DETAILS_URL") or None
    scp_host = values.get("VENTT_SCP_HOST") or None
    scp_root_dir = values.get("VENTT_SCP_ROOT_DIR") or None
    return username, password, user_details_url, scp_host, scp_root_dir


def main() -> int:
    logger = setup_logging()
    logger.info("=== VENTT Export Agent gestart ===")

    try:
        username, password, user_details_url, scp_host, scp_root_dir = load_env()
    except PortalError as e:
        logger.error(str(e))
        return 1

    client = VenttPortalClient(username, password, logger, user_details_url)

    try:
        client.login()
        bridge_ids = client.discover_bridges()

        if not bridge_ids:
            logger.warning("Geen bridges gevonden op dit account. Niets te exporteren.")
            return 0

        all_processed: list[Path] = []
        failures: list[str] = []

        for bridge_id in bridge_ids:
            try:
                job_id = client.start_export(bridge_id)
                status_data = client.wait_for_export(job_id)

                today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
                zip_path = RAW_DIR / today / f"{bridge_id}.zip"
                client.download_export(job_id, zip_path)

                processed = extract_and_process_zip(zip_path, bridge_id, logger)
                all_processed.extend(processed)

                logger.info(
                    f"Bridge {bridge_id} klaar. "
                    f"Entities: {status_data.get('entityCount')}, "
                    f"Grootte: {status_data.get('size')} bytes"
                )
            except Exception as e:  # noqa: BLE001 - we willen per-bridge falen, niet de hele run
                logger.error(f"Fout bij bridge {bridge_id}: {e}")
                failures.append(bridge_id)

        logger.info(
            f"=== Klaar. {len(all_processed)} CSV(s) verwerkt, "
            f"{len(failures)} bridge(s) mislukt. ==="
        )
        if failures:
            logger.warning(f"Mislukte bridges: {failures}")

        today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        dashboard_path: Path | None = None
        try:
            dashboard_path = generate_dashboard(today, logger)
        except Exception as e:  # noqa: BLE001 - dashboard-fout mag de exportrun niet laten falen
            logger.error(f"Dashboard genereren mislukt: {e}")

        if scp_host and scp_root_dir:
            try:
                upload_day_to_server(today, scp_host, scp_root_dir, dashboard_path, logger)
            except Exception as e:  # noqa: BLE001 - upload-fout mag de exportrun niet laten falen
                logger.error(f"Upload naar server mislukt: {e}")
        else:
            logger.info(
                "VENTT_SCP_HOST en/of VENTT_SCP_ROOT_DIR niet gezet in .env, "
                "upload naar server overgeslagen."
            )

        if failures:
            return 2
        return 0

    except PortalError as e:
        logger.error(f"Portal-fout: {e}")
        return 1
    except requests.RequestException as e:
        logger.error(f"Netwerkfout: {e}")
        return 1


if __name__ == "__main__":
    sys.exit(main())
