"""
Background Scheduler for automated scan jobs

Uses APScheduler for periodic scan execution.
"""
import logging
import json
import os
from datetime import datetime, timedelta

logger = logging.getLogger(__name__)

try:
    from apscheduler.schedulers.asyncio import AsyncIOScheduler
    from apscheduler.triggers.interval import IntervalTrigger
    from apscheduler.triggers.cron import CronTrigger
    HAS_APSCHEDULER = True
except ImportError:
    HAS_APSCHEDULER = False
    logger.warning("apscheduler not installed - automated scans disabled. Install with: pip install apscheduler")

from app.database import SessionLocal
from app.models.scan_schedule import ScanSchedule, ScheduleInterval
from app.models.asset import Asset
from app.models.scan import Scan, ScanType, ScanStatus
from app.models.setting import Setting
from app.models.vulnerability import Vulnerability, VulnerabilityStatus, VulnerabilitySeverity
from app.models.policy import Policy
from app.models.notification_log import NotificationLog, NotificationType, NotificationStatus
from app.models.user import User
from app.integrations.wazuh_client import WazuhClient

scheduler = AsyncIOScheduler() if HAS_APSCHEDULER else None

INTERVAL_MAP = {
    ScheduleInterval.EVERY_HOUR: {"hours": 1},
    ScheduleInterval.EVERY_6_HOURS: {"hours": 6},
    ScheduleInterval.EVERY_12_HOURS: {"hours": 12},
    ScheduleInterval.DAILY: {"days": 1},
    ScheduleInterval.WEEKLY: {"weeks": 1},
}


def execute_scheduled_scan(schedule_id: int):
    """Executes a scheduled scan. Routes by ScanSchedule.scanner_type
    to either the Wazuh agent loop or the Nessus sync service."""
    db = SessionLocal()
    try:
        schedule = db.query(ScanSchedule).filter(ScanSchedule.id == schedule_id).first()
        if not schedule or not schedule.enabled:
            return

        scanner = (getattr(schedule, "scanner_type", None) or "wazuh").lower()
        logger.info(
            f"Scheduled scan '{schedule.name}' (ID: {schedule_id}, scanner={scanner}) started"
        )

        # ---- Nessus branch ----
        if scanner == "nessus":
            try:
                from app.services.nessus_sync import run_nessus_sync
                stats = run_nessus_sync(db)
                logger.info(f"Scheduled Nessus sync done: {stats}")
            except Exception as e:
                logger.error(f"Scheduled Nessus sync failed: {e}")
            schedule.last_run = datetime.now()
            # next_run derivation: ask the actual APScheduler trigger
            # when it will fire next. INTERVAL_MAP lookup with default
            # `{"days": 1}` was wrong for CRON schedules — it always
            # wrote a fake +24h next_run even when the cron fires every
            # minute. Trigger.get_next_fire_time gives the authoritative
            # answer for both interval and cron triggers.
            try:
                from apscheduler.schedulers.asyncio import AsyncIOScheduler  # type: ignore  # noqa: F401
                job = scheduler.get_job(f"scan_schedule_{schedule.id}") if scheduler else None
                next_fire = job.next_run_time if job else None
            except Exception:
                next_fire = None
            if next_fire is not None:
                # strip tz so it matches the rest of the column (naive)
                schedule.next_run = next_fire.replace(tzinfo=None)
            else:
                interval_delta = INTERVAL_MAP.get(schedule.interval, {"days": 1})
                schedule.next_run = datetime.now() + timedelta(**interval_delta)
            db.commit()
            return

        # ---- Wazuh branch (default, existing behaviour) ----
        # Wazuh Config laden (transparently decrypted)
        from app.auth.setting_crypto import read_setting_value
        raw_wazuh = read_setting_value(db, "wazuh_config")
        if not raw_wazuh:
            logger.error("Scheduled scan aborted: Wazuh configuration missing")
            return

        config = json.loads(raw_wazuh)
        assets = db.query(Asset).filter(Asset.wazuh_agent_id.isnot(None)).all()
        if not assets:
            logger.info("No Wazuh assets found for scheduled scan")
            schedule.last_run = datetime.now()
            db.commit()
            return

        triggered = 0
        errors = []

        try:
            with WazuhClient(
                base_url=config.get("api_url"),
                username=config.get("username"),
                password=config.get("password"),
                indexer_url=config.get("indexer_url"),
                indexer_username=config.get("indexer_username"),
                indexer_password=config.get("indexer_password"),
                verify_ssl=config.get("verify_ssl", False)
            ) as client:
                # Sync vulnerabilities for each agent
                from app.routers.vulnerabilities import (
                    sync_agent_vulnerabilities, reconcile_empty_agents,
                )
                # Shared across the run so an agent that returns nothing can be
                # judged against the run as a whole (see reconcile_empty_agents).
                run_stats: dict = {}
                for asset in assets:
                    try:
                        scan = Scan(
                            asset_id=asset.id,
                            scan_type=ScanType.WAZUH,  # source-labelled (was FULL)
                            status=ScanStatus.RUNNING,
                            started_at=datetime.now()
                        )
                        db.add(scan)

                        sync_agent_vulnerabilities(db, client, asset.wazuh_agent_id,
                                                   asset, run_stats=run_stats)

                        scan.status = ScanStatus.COMPLETED
                        scan.completed_at = datetime.now()
                        triggered += 1
                    except Exception as e:
                        scan.status = ScanStatus.FAILED
                        scan.error_message = str(e)
                        errors.append(f"{asset.hostname}: {e}")
                        logger.error(f"Scheduled scan error for {asset.hostname}: {e}")

                # Now that the run is done, agents that returned nothing can be
                # judged: real emptiness if the API answered for anyone else.
                try:
                    reconcile_empty_agents(db, run_stats)
                except Exception as e:
                    logger.error(f"Wazuh empty-agent reconcile failed: {e}")

        except Exception as e:
            logger.error(f"Wazuh connection error during scheduled scan: {e}")

        schedule.last_run = datetime.now()
        interval_delta = INTERVAL_MAP.get(schedule.interval, {"days": 1})
        schedule.next_run = datetime.now() + timedelta(**interval_delta)
        db.commit()

        logger.info(f"Scheduled scan '{schedule.name}' completed: {triggered} assets scanned, {len(errors)} errors")

    except Exception as e:
        logger.error(f"Scheduled scan error: {e}")
    finally:
        db.close()


def check_sla_breaches():
    """
    Hourly SLA-breach check. Honors notification_mode setting:
      - 'digest' (default): one summary mail per recipient
      - 'single':          one mail per (vuln, recipient) as before
    Per-vuln 24h throttle still applies in both modes.

    Master toggle: setting `sla_breach_enabled` (default true). When
    set to "false" the entire job is a no-op — covers the case where
    the operator disabled all Security Policies but doesn't want any
    SLA mails firing for assets that fall through to default_sla.
    """
    db = SessionLocal()
    try:
        from app.services.email_service import (
            get_smtp_config,
            get_notification_mode,
            get_email_template,
            render_template,
            send_email,
            send_sla_breach_digest,
        )
        from app.models.setting import Setting

        # Master toggle — string "false" disables the whole check.
        sla_enabled_row = db.query(Setting).filter(
            Setting.key == "sla_breach_enabled"
        ).first()
        if sla_enabled_row and (sla_enabled_row.value or "").strip().lower() in ("false", "0", "no", "off"):
            logger.info("SLA breach check skipped — setting sla_breach_enabled is off")
            return

        if not get_smtp_config(db):
            return

        vulns = db.query(Vulnerability).join(Asset).filter(
            Vulnerability.status == VulnerabilityStatus.open,
            Vulnerability.notification_suppressed == False
        ).all()
        if not vulns:
            return

        now = datetime.now()
        mode = get_notification_mode(db)
        dashboard_url = os.getenv("DASHBOARD_URL", "http://localhost:3000").rstrip("/") + "/vulnerabilities"

        default_sla = {
            VulnerabilitySeverity.critical: 2,
            VulnerabilitySeverity.high: 7,
            VulnerabilitySeverity.medium: 30,
            VulnerabilitySeverity.low: 90,
            VulnerabilitySeverity.none: 365,
        }

        # Bucket per recipient email so the digest stays one-mail-per-person.
        # buckets[email] = {user_id, username, items: [{vuln, hours_overdue, asset, assigned_name}, ...]}
        buckets: dict[str, dict] = {}

        def _resolve_recipients(vuln):
            recs = []
            seen = set()

            def _push(u):
                if u and u.email and u.email not in seen:
                    recs.append((u.id, u.email, u.username))
                    seen.add(u.email)

            if vuln.assigned_user_id:
                _push(db.query(User).filter(User.id == vuln.assigned_user_id).first())
            elif vuln.assigned_group_id:
                from app.models.group import Group
                g = db.query(Group).filter(Group.id == vuln.assigned_group_id).first()
                if g:
                    for u in g.users:
                        _push(u)
            elif vuln.asset and vuln.asset.assigned_user_id:
                _push(db.query(User).filter(User.id == vuln.asset.assigned_user_id).first())
            elif vuln.asset and vuln.asset.groups:
                # Asset uses M2M groups (no single assigned_group_id column)
                for g in vuln.asset.groups:
                    for u in g.users:
                        _push(u)
            return recs

        # Phase 1: classify breaches + de-duplicate / throttle
        for vuln in vulns:
            asset = vuln.asset

            if asset.policy_id:
                policy = db.query(Policy).filter(Policy.id == asset.policy_id).first()
                if policy:
                    # Disabled policy = operator opted out of SLA tracking
                    # for these assets. Skip breach evaluation entirely so
                    # no notification is generated. Tester reported mails
                    # kept arriving after disabling all policies.
                    from app.models.policy import PolicyStatus
                    if policy.status == PolicyStatus.DISABLED:
                        continue
                    sla_map = {
                        VulnerabilitySeverity.critical: policy.critical_sla_days,
                        VulnerabilitySeverity.high: policy.high_sla_days,
                        VulnerabilitySeverity.medium: policy.medium_sla_days,
                        VulnerabilitySeverity.low: policy.low_sla_days,
                        VulnerabilitySeverity.none: 365,
                    }
                    sla_days = sla_map.get(vuln.severity, 30)
                else:
                    sla_days = default_sla.get(vuln.severity, 30)
            else:
                sla_days = default_sla.get(vuln.severity, 30)

            deadline = vuln.detected_at + timedelta(days=sla_days)
            if now <= deadline:
                continue
            hours_overdue = int((now - deadline).total_seconds() / 3600)

            # Per-vuln 24h throttle still applies in both modes — prevents
            # repeat-pinging on the same finding if the hourly scheduler keeps firing.
            recent = db.query(NotificationLog).filter(
                NotificationLog.vulnerability_id == vuln.id,
                NotificationLog.notification_type == NotificationType.SLA_BREACH,
                NotificationLog.sent_at >= now - timedelta(hours=24)
            ).first()
            if recent:
                continue

            recipients = _resolve_recipients(vuln)
            if not recipients:
                continue

            assigned_name = "Unassigned"
            if vuln.assigned_user:
                assigned_name = vuln.assigned_user.username
            elif vuln.assigned_group_id:
                assigned_name = "Group"
            elif asset.assigned_user:
                assigned_name = asset.assigned_user.username
            elif asset.groups:
                assigned_name = "Group"

            for r_uid, r_email, r_username in recipients:
                bucket = buckets.setdefault(r_email, {
                    "user_id": r_uid,
                    "username": r_username,
                    "items": [],
                })
                bucket["items"].append({
                    "vuln": vuln,
                    "asset": asset,
                    "hours_overdue": hours_overdue,
                    "assigned_name": assigned_name,
                })

        if not buckets:
            return

        notifications_sent = 0
        checked_at_str = now.strftime("%Y-%m-%d %H:%M UTC")

        # Phase 2: dispatch
        for email, bucket in buckets.items():
            items = bucket["items"]
            user_id = bucket["user_id"]
            username = bucket["username"]

            if mode == "digest":
                digest_items = [
                    {
                        "cve_id": it["vuln"].cve_id,
                        "severity": it["vuln"].severity.value if it["vuln"].severity else "none",
                        "asset_hostname": it["asset"].hostname,
                        "detected_at": it["vuln"].detected_at.strftime("%Y-%m-%d %H:%M") if it["vuln"].detected_at else "—",
                        "hours_overdue": it["hours_overdue"],
                    }
                    for it in items
                ]
                success, err = send_sla_breach_digest(
                    db,
                    to_email=email,
                    recipient_name=username,
                    items=digest_items,
                    checked_at=checked_at_str,
                    dashboard_url=dashboard_url,
                )

                # One NotificationLog row per vuln so the per-vuln 24h throttle
                # remains effective on the next hourly tick.
                for it in items:
                    db.add(NotificationLog(
                        vulnerability_id=it["vuln"].id,
                        asset_id=it["asset"].id,
                        user_id=user_id,
                        notification_type=NotificationType.SLA_BREACH,
                        sent_at=now,
                        subject=f"[TRUEVULN] {len(items)} SLA-breached vulnerabilities require action",
                        recipient_email=email,
                        status=NotificationStatus.SENT if success else NotificationStatus.FAILED,
                        message_body=f"SLA digest: {it['vuln'].cve_id} on {it['asset'].hostname} ({it['hours_overdue']}h overdue)",
                        error_message=None if success else err,
                    ))
                if success:
                    notifications_sent += 1
                else:
                    logger.warning("SLA digest mail failed for %s: %s", email, err)
            else:
                # legacy single-mail mode — one mail per vuln per recipient
                subject_template, body_template = get_email_template(db, "email_template_sla_breach")
                for it in items:
                    vuln = it["vuln"]
                    asset = it["asset"]
                    variables = {
                        "cve_id": vuln.cve_id,
                        "severity": vuln.severity.value if vuln.severity else "unknown",
                        "severity_upper": vuln.severity.value.upper() if vuln.severity else "UNKNOWN",
                        "cvss_score": str(vuln.cvss_score or "N/A"),
                        "asset_hostname": asset.hostname,
                        "hours_overdue": str(it["hours_overdue"]),
                        "assigned_user": it["assigned_name"],
                        "recipient_name": username,
                        "package_name": vuln.package_name or "N/A",
                        "title": vuln.title or "N/A",
                        "detected_at": vuln.detected_at.strftime("%Y-%m-%d %H:%M UTC") if vuln.detected_at else "N/A",
                        "dashboard_url": dashboard_url,
                    }
                    subject = render_template(subject_template, variables)
                    body = render_template(body_template, variables)
                    success, error_msg = send_email(db, email, subject, body)
                    db.add(NotificationLog(
                        vulnerability_id=vuln.id,
                        asset_id=asset.id,
                        user_id=user_id,
                        notification_type=NotificationType.SLA_BREACH,
                        sent_at=now,
                        subject=subject,
                        recipient_email=email,
                        status=NotificationStatus.SENT if success else NotificationStatus.FAILED,
                        message_body=body,
                        error_message=None if success else error_msg,
                    ))
                    if success:
                        notifications_sent += 1

        db.commit()

        if notifications_sent > 0:
            logger.info(
                f"SLA breach check ({mode} mode): {notifications_sent} mails sent, "
                f"{len(buckets)} recipients, "
                f"{sum(len(b['items']) for b in buckets.values())} breached findings"
            )

    except Exception as e:
        logger.error(f"SLA breach check error: {e}")
    finally:
        db.close()


def refresh_threat_intel_enrichment():
    """Daily refresh of EPSS scores + CISA KEV catalog for all open vulnerabilities."""
    from app.services.enrichment_service import enrich_all_open_vulnerabilities

    db = SessionLocal()
    try:
        stats = enrich_all_open_vulnerabilities(db)
        logger.info(
            f"Threat intel refresh done: "
            f"{stats['total']} open vulns, epss_updated={stats['epss_updated']}, "
            f"kev_marked={stats['kev_marked']}, kev_cleared={stats['kev_cleared']}"
        )
    except Exception as e:
        logger.error(f"Threat intel refresh failed: {e}")
    finally:
        db.close()


def exploit_intel_nightly():
    """Refresh public-exploit catalogs (Exploit-DB / PoC-in-GitHub /
    Metasploit). Runs 03:45 UTC — after Vulnrichment (03:00) and the
    audit prune (03:30), before URS recompute (04:00) so the new
    priority_score values feed the URS run.

    Sources cached on disk 24h — the job is idempotent.
    """
    from app.services.exploit_intel_service import refresh_all_exploit_intel

    db = SessionLocal()
    try:
        stats = refresh_all_exploit_intel(db, only_open=True)
        logger.info("exploit-intel nightly done: %s", stats)
    except Exception as e:
        logger.error("exploit-intel nightly failed: %s", e)
        db.rollback()
    finally:
        db.close()


def eol_check_nightly():
    """Run endoflife.date EOL detection for every Wazuh-linked asset.

    Pseudo-CVE rows (cve_id starts with EOL-) get upserted so the next
    morning's vuln list highlights newly-EOL software. Cheap — only
    products in the mapping table get scanned; unmapped names skipped.

    Slotted at 03:15 UTC so it runs after SCA (02:00), before
    Vulnrichment (03:30 = after the audit prune) — leaves a gap so it
    doesn't compete for DB connections.
    """
    from app.integrations.wazuh_client import WazuhClient
    from app.models.asset import Asset
    from app.services import eol_service
    from app.auth.setting_crypto import read_setting_value
    import json as _json

    db = SessionLocal()
    try:
        raw = read_setting_value(db, "wazuh_config")
        if not raw:
            logger.info("EOL check skipped — wazuh_config not set")
            return
        try:
            cfg = _json.loads(raw)
        except _json.JSONDecodeError:
            logger.warning("EOL check skipped — invalid wazuh_config JSON")
            return
        if not all([cfg.get("api_url"), cfg.get("username"), cfg.get("password")]):
            logger.info("EOL check skipped — wazuh_config incomplete")
            return
        wazuh = WazuhClient(
            base_url=cfg.get("api_url"),
            username=cfg.get("username"),
            password=cfg.get("password"),
            indexer_url=cfg.get("indexer_url"),
            indexer_username=cfg.get("indexer_username"),
            indexer_password=cfg.get("indexer_password"),
            verify_ssl=cfg.get("verify_ssl", False),
        )
        assets = db.query(Asset).filter(Asset.wazuh_agent_id.isnot(None)).all()
        upserts = 0
        for asset in assets:
            # OS-level EOL first (independent of package fetch).
            try:
                os_status = eol_service.check_os_eol(
                    db, asset.operating_system or "", asset.os_version or ""
                )
                if os_status and (os_status.is_eol or os_status.is_eol_soon or os_status.is_eoas):
                    eol_service.upsert_eol_vulnerability(
                        db,
                        asset_id=asset.id,
                        product_name=(asset.operating_system or "Operating System").strip(),
                        installed_version=(asset.os_version or os_status.release_name or "unknown"),
                        status=os_status,
                    )
                    upserts += 1
            except Exception as e:
                logger.warning("EOL: OS check failed for %s: %s", asset.hostname, e)

            try:
                pkgs = wazuh.get_packages(asset.wazuh_agent_id) or []
            except Exception as e:
                logger.warning("EOL: package fetch failed for %s: %s", asset.hostname, e)
                continue
            seen: set[tuple[str, str]] = set()
            for pkg in pkgs:
                name = (pkg.get("name") or "").strip()
                version = (pkg.get("version") or "").strip()
                if not name or not version:
                    continue
                key = (name.lower(), version)
                if key in seen:
                    continue
                seen.add(key)
                status = None
                if eol_service.resolve_product_slug(name):
                    try:
                        status = eol_service.check_eol(db, name, version)
                    except Exception:
                        status = None
                actionable = status and (status.is_eol or status.is_eol_soon or status.is_eoas)
                # MS-lifecycle fallback whenever endoflife.date had nothing
                # actionable (covers VC++ Redistributables, exotics, and MS
                # products endoflife.date can't resolve a release for).
                if not actionable:
                    try:
                        from app.services import ms_lifecycle_service
                        ms_status = ms_lifecycle_service.resolve_ms_lifecycle_eol(db, name, version)
                        if ms_status and (ms_status.is_eol or ms_status.is_eol_soon):
                            status = ms_status
                            actionable = True
                    except Exception as e:
                        logger.debug("MS-lifecycle nightly fallback failed for %s: %s", name, e)
                if not actionable:
                    continue
                try:
                    eol_service.upsert_eol_vulnerability(
                        db,
                        asset_id=asset.id,
                        product_name=name,
                        installed_version=version,
                        status=status,
                    )
                    upserts += 1
                except Exception as e:
                    logger.warning("EOL upsert failed on asset %s: %s", asset.id, e)
        db.commit()
        logger.info("EOL nightly: %d assets scanned, %d EOL upserts", len(assets), upserts)
    except Exception as e:
        logger.error("EOL nightly failed: %s", e)
        db.rollback()
    finally:
        db.close()


def intune_sync_nightly():
    """Sync Microsoft Intune managed devices → assets + OS-EOL (Graph API).

    Skipped when intune_config is not set. Slotted at 02:10 UTC, before the
    other inventory-derived jobs."""
    from app.services.intune_service import load_intune_config, run_intune_sync
    db = SessionLocal()
    try:
        if not load_intune_config(db):
            logger.info("Intune sync skipped — intune_config not set")
            return
        stats = run_intune_sync(db)
        logger.info("Intune nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
    except Exception as e:
        logger.error("Intune nightly failed: %s", e)
        db.rollback()
    finally:
        db.close()


def m365_check_nightly():
    """Detect Microsoft 365 Apps CVEs (Plan P) for every Wazuh-linked asset.

    M365 Apps security fixes never reach NVD and are invisible to Wazuh's
    vulnerability detector. This parses the MS365 Apps security-updates
    page (cached 24h), compares the installed build per channel, and
    upserts real-CVE rows for the months each host is behind on.

    Slotted at 03:20 UTC — right after the EOL nightly (03:15), before the
    audit prune (03:30).
    """
    from app.integrations.wazuh_client import WazuhClient
    from app.services import m365_service
    from app.auth.setting_crypto import read_setting_value
    import json as _json

    db = SessionLocal()
    try:
        raw = read_setting_value(db, "wazuh_config")
        if not raw:
            logger.info("M365 check skipped — wazuh_config not set")
            return
        try:
            cfg = _json.loads(raw)
        except _json.JSONDecodeError:
            logger.warning("M365 check skipped — invalid wazuh_config JSON")
            return
        if not all([cfg.get("api_url"), cfg.get("username"), cfg.get("password")]):
            logger.info("M365 check skipped — wazuh_config incomplete")
            return
        wazuh = WazuhClient(
            base_url=cfg.get("api_url"),
            username=cfg.get("username"),
            password=cfg.get("password"),
            indexer_url=cfg.get("indexer_url"),
            indexer_username=cfg.get("indexer_username"),
            indexer_password=cfg.get("indexer_password"),
            verify_ssl=cfg.get("verify_ssl", False),
        )
        stats = m365_service.run_m365_check(db, wazuh)
        logger.info("M365 nightly: %s", stats)
    except m365_service.M365Error as e:
        logger.warning("M365 nightly skipped — %s", e)
    except Exception as e:
        logger.error("M365 nightly failed: %s", e)
        db.rollback()
    finally:
        db.close()


def app_cve_scan_nightly():
    """Built-in app→CVE scanner for every asset with software inventory.

    Maps installed software (Wazuh packages + Intune detectedApps) to real
    CVEs via OSV / NVD-CPE (curated + precise, own version-range check) —
    closes the coverage gap for Intune-only / mobile devices that have no
    real scanner. Source 'app-scan'; cross-confirms with the other scanners.

    Slotted at 03:25 UTC — after M365 (03:20), before the audit prune (03:30).
    Cache (TTL 7d) keeps OSV/NVD load bounded; NVD_API_KEY recommended.
    """
    from app.services import app_cve_scanner_service, cvelistv5_scan_service
    db = SessionLocal()
    try:
        # Rebuild the cvelistV5 reverse index first (one ~557 MB ZIP walk) so
        # the scan below has fresh product→CVE ranges for curated software.
        try:
            cvelistv5_scan_service.build_product_index(db)
        except Exception as e:
            logger.warning("cvelistV5 index build failed (non-fatal): %s", e)
        stats = app_cve_scanner_service.run_app_cve_scan(db)
        logger.info("App CVE scan nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
        # Auto-suppress Wazuh findings whose installed version is outside all
        # cvelistV5 affected ranges (loose-CPE false positives).
        try:
            fp = cvelistv5_scan_service.suppress_false_positives(db)
            logger.info("cvelistV5 FP-suppression nightly: %s", {k: v for k, v in fp.items() if k != "errors"})
        except Exception as e:
            logger.warning("cvelistV5 FP-suppression failed (non-fatal): %s", e)
    except Exception as e:
        logger.error("App CVE scan nightly failed: %s", e)
        db.rollback()
    finally:
        db.close()


def msrc_refresh_weekly():
    """Ingest the recent monthly MSRC CVRF documents into cve_remediations.

    Microsoft revises advisories (containment-only first, KBs later), so a
    weekly pull keeps the per-CVE fixes/workarounds/mitigations current.
    """
    from app.services import msrc_service
    db = SessionLocal()
    try:
        stats = msrc_service.refresh_msrc(db)
        logger.info("MSRC weekly: %s", {k: v for k, v in stats.items() if k != "errors"})
    except Exception as e:
        logger.error("MSRC weekly refresh failed: %s", e)
        db.rollback()
    finally:
        db.close()


def msrc_scan_nightly():
    """Rebuild the MSRC fixed-build index and flag Windows-Server OS CVEs whose
    FixedBuild is ahead of the host's build.

    MSRC publishes on Patch Tuesday, well before the CVE reaches the Wazuh CTI
    feed — this closes that gap, and it is patch-level accurate (NVD/cvelistV5
    carry no fixed build for MS products, see msrc_scan_service).
    """
    from app.services import msrc_scan_service
    db = SessionLocal()
    try:
        msrc_scan_service.build_product_index(db)
        stats = msrc_scan_service.run_msrc_scan(db)
        logger.info("MSRC scan nightly: %s", {k: v for k, v in stats.items() if k != "errors"})
    except Exception as e:
        logger.error("MSRC scan nightly failed: %s", e)
        db.rollback()
    finally:
        db.close()


def prune_audit_logs_nightly():
    """Prune audit_logs older than `audit_log_retention_days` setting.

    Default 1825 days (≈ 5 years) so ISO 27001 / SOX / DSGVO Art.5
    retention windows are covered. Setting key:
      `audit_log_retention_days` — integer string. 0 = keep forever.

    Runs 03:30 UTC, between Vulnrichment (03:00) and URS (04:00).
    """
    from app.models.audit_log import AuditLog
    from app.models.setting import Setting
    from datetime import timedelta

    db = SessionLocal()
    try:
        row = db.query(Setting).filter(Setting.key == "audit_log_retention_days").first()
        try:
            days = int((row.value if row else "1825").strip())
        except (ValueError, AttributeError):
            days = 1825
        if days <= 0:
            logger.info("audit-log prune skipped (retention_days=0 → keep forever)")
            return

        cutoff = datetime.now() - timedelta(days=days)
        pruned = (
            db.query(AuditLog)
            .filter(AuditLog.timestamp < cutoff)
            .delete(synchronize_session=False)
        )
        db.commit()
        logger.info("audit-log prune: deleted %d entries older than %d days", pruned, days)
    except Exception as e:
        logger.error("audit-log prune failed: %s", e)
        db.rollback()
    finally:
        db.close()


def reconcile_assets_nightly():
    """Soft-inactivate assets no source has reported within the window;
    revive recently-seen inactive ones. Runs 04:15 UTC, after URS."""
    from app.services.asset_lifecycle import reconcile_asset_lifecycle
    db = SessionLocal()
    try:
        stats = reconcile_asset_lifecycle(db)
        logger.info("asset lifecycle nightly: %s", stats)
    except Exception as e:
        logger.error("asset lifecycle nightly failed: %s", e)
        db.rollback()
    finally:
        db.close()


def refresh_exposure_nightly():
    """Refresh network-exposure scores from Wazuh syscollector ports.
    Runs 02:30 UTC, after SCA (02:00)."""
    from app.auth.setting_crypto import read_setting_value
    from app.services.exposure_service import refresh_all_exposure
    import json as _json
    db = SessionLocal()
    try:
        raw = read_setting_value(db, "wazuh_config")
        if not raw:
            logger.info("exposure nightly skipped — wazuh_config not set")
            return
        cfg = _json.loads(raw)
        if not all([cfg.get("api_url"), cfg.get("username"), cfg.get("password")]):
            logger.info("exposure nightly skipped — wazuh_config incomplete")
            return
        wazuh = WazuhClient(
            base_url=cfg.get("api_url"),
            username=cfg.get("username"),
            password=cfg.get("password"),
            indexer_url=cfg.get("indexer_url"),
            indexer_username=cfg.get("indexer_username"),
            indexer_password=cfg.get("indexer_password"),
            verify_ssl=cfg.get("verify_ssl", False),
        )
        stats = refresh_all_exposure(db, wazuh)
        logger.info("exposure nightly: %s", stats)
    except Exception as e:
        logger.error("exposure nightly failed: %s", e)
        db.rollback()
    finally:
        db.close()


def recompute_urs_nightly():
    """Recompute Unified Risk Score (URS) for every asset and snapshot.

    Runs 04:00 UTC — after SCA refresh (02:00) and Vulnrichment
    correction (03:00) so AVS/ASS inputs are fresh. Snapshots written
    by compute_urs_for_all() feed the 7-day trend arrow.

    Also prunes asset_risk_snapshots older than 90 days.
    """
    from app.models.compliance import AssetRiskSnapshot
    from app.services.urs_service import compute_urs_for_all
    from datetime import timedelta

    db = SessionLocal()
    try:
        stats = compute_urs_for_all(db, avs_mode="hybrid")
        db.commit()
        cutoff = datetime.now() - timedelta(days=90)
        pruned = (
            db.query(AssetRiskSnapshot)
            .filter(AssetRiskSnapshot.snapshot_date < cutoff)
            .delete(synchronize_session=False)
        )
        db.commit()
        logger.info(
            "URS nightly recompute done: %d assets, %d with URS, %d errors, %d snapshots pruned",
            stats["assets"], stats["with_urs"], stats["errors"], pruned,
        )
    except Exception as e:
        logger.error("URS nightly recompute failed: %s", e)
    finally:
        db.close()


def refresh_compliance_sca():
    """Nightly Wazuh SCA refresh — pulls /sca/{agent_id} for every
    asset that has a wazuh_agent_id and upserts compliance_results.

    Cheap operation per asset (one summary GET, no per-check fetch)
    so a 100-asset fleet completes in seconds.
    """
    from app.services.compliance_service import refresh_all_compliance

    db = SessionLocal()
    try:
        stats = refresh_all_compliance(db)
        logger.info(
            "Compliance SCA refresh done: %d assets, %d policy results, %d errors",
            stats["assets_synced"], stats["policies_synced"], len(stats["errors"]),
        )
    except Exception as e:
        logger.error("Compliance SCA refresh failed: %s", e)
    finally:
        db.close()


def refresh_cisa_vulnrichment():
    """Nightly job — pull CISA Vulnrichment ZIP snapshot + correct CVSS / SSVC.

    CISA commits to cisagov/vulnrichment several times per day. Running
    this nightly catches CVSS revisions, new SSVC decision points and
    newly-published CVEs that were 404 yesterday. Uses the same
    correct_vulnerability_scores helper as the manual 'Correct CVSS'
    button, so behaviour matches what the operator sees in the UI.

    Held score corrections (exploitation_source='vulnrichment') are
    preserved across Nessus syncs by the existing override-lock —
    see services/nessus_sync.py run_nessus_sync merge branch.
    """
    from app.services.vuln_override_service import correct_vulnerability_scores

    db = SessionLocal()
    try:
        result = correct_vulnerability_scores(db, dry_run=False)
        logger.info(
            "CISA Vulnrichment nightly correction done: "
            "checked=%s, updated=%s, not_found=%s",
            result.get("checked", 0),
            result.get("updated", 0),
            result.get("not_found", 0),
        )
    except Exception as e:
        logger.error("CISA Vulnrichment nightly refresh failed: %s", e)
    finally:
        db.close()


def sync_schedules():
    """Synchronizes DB schedules with APScheduler jobs"""
    if not HAS_APSCHEDULER or scheduler is None:
        return

    db = SessionLocal()
    try:
        schedules = db.query(ScanSchedule).filter(ScanSchedule.enabled == True).all()

        # Aktuelle Scheduler-Jobs entfernen (nur scan_schedule_ prefixed)
        existing_jobs = {j.id for j in scheduler.get_jobs() if j.id.startswith("scan_schedule_")}
        active_ids = {f"scan_schedule_{s.id}" for s in schedules}

        # Remove jobs that are no longer active
        for job_id in existing_jobs - active_ids:
            scheduler.remove_job(job_id)
            logger.info(f"Scheduler job removed: {job_id}")

        # Füge neue/aktualisierte Jobs hinzu
        for schedule in schedules:
            job_id = f"scan_schedule_{schedule.id}"
            interval_kwargs = INTERVAL_MAP.get(schedule.interval, {"days": 1})
            
            # Prüfe ob der Job bereits existiert
            existing_job = next((j for j in scheduler.get_jobs() if j.id == job_id), None)
            
            if existing_job:
                # Nur Reschedule wenn sich das Intervall geändert hat? 
                # Da wir hier schwer das Intervall des bestehenden Triggers prüfen können,
                # vergleichen wir einfach ob der Job-Name (Intervall-Name) noch passt oder 
                # ob wir eine Änderung in der DB erzwingen wollen.
                # Wir verzichten auf das generelle Reschedule jede Minute!
                continue
            else:
                # Neuer Job oder nach App-Neustart
                if schedule.interval == ScheduleInterval.CRON and schedule.cron_expression:
                    try:
                        trigger = CronTrigger.from_crontab(schedule.cron_expression)
                    except Exception as e:
                        logger.error(f"Invalid cron expression for schedule {schedule.id}: {e}")
                        continue
                else:
                    # Wenn next_run in der Vergangenheit liegt, sofort ausführen (start_date=now)
                    start_date = schedule.next_run if (schedule.next_run and schedule.next_run > datetime.now()) else datetime.now()
                    trigger = IntervalTrigger(start_date=start_date, **interval_kwargs)
                
                scheduler.add_job(
                    execute_scheduled_scan,
                    trigger=trigger,
                    id=job_id,
                    args=[schedule.id],
                    name=f"{schedule.name} ({schedule.interval})",
                    replace_existing=True
                )
                logger.info(f"Scheduler job added: {job_id} (Trigger: {trigger})")

            # Update next_run calculation
            if not schedule.next_run:
                if schedule.interval == ScheduleInterval.CRON and schedule.cron_expression:
                    schedule.next_run = trigger.get_next_fire_time(None, datetime.now())
                else:
                    schedule.next_run = datetime.now() + timedelta(**interval_kwargs)

        db.commit()
        logger.info(f"Scheduler synchronized: {len(schedules)} active schedules")

    except Exception as e:
        logger.error(f"Scheduler sync error: {e}")
    finally:
        db.close()


def new_vuln_digest_nightly():
    """Nightly roundup of all new CVEs → one aggregated mail per recipient.

    Registered hourly and self-gates on the configured hour, so both the
    on/off (notification_schedule) and the send hour (notification_nightly_hour)
    are GUI-configurable without re-registering the job.
    ponytail: 24 cheap no-op checks/day beats re-registration plumbing.
    """
    from datetime import datetime as _dt
    db = SessionLocal()
    try:
        from app.services.email_service import (
            get_notification_schedule, get_notification_nightly_hour,
            send_nightly_new_vuln_digest,
        )
        if get_notification_schedule(db) != "nightly":
            return
        if _dt.now().hour != get_notification_nightly_hour(db):
            return
        stats = send_nightly_new_vuln_digest(db)
        logger.info("Nightly new-vuln digest sent: %s", stats)
    except Exception as e:
        logger.error("Nightly new-vuln digest failed: %s", e)
    finally:
        db.close()


def advisory_feeds_refresh():
    """Refresh the security-advisory RSS feeds (ZDI/CERT-EU/BSI/...) so the
    advisories page serves from cache. Every 6h — these sources publish ahead
    of NVD/cvelistV5, that's their whole value."""
    from app.services.advisory_feed_service import refresh_feeds
    db = SessionLocal()
    try:
        stats = refresh_feeds(db)
        logger.info("Advisory feeds refresh: %s", stats)
    except Exception as e:
        logger.error("Advisory feeds refresh failed: %s", e)
    finally:
        db.close()


def start_scheduler():
    """Starts the background scheduler"""
    if not HAS_APSCHEDULER or scheduler is None:
        logger.warning("Scheduler not available - install apscheduler: pip install apscheduler")
        return

    try:
        sync_schedules()
    except Exception as e:
        logger.warning(f"Initial scheduler synchronization failed (DB unchecked?): {e}")

    # Periodic re-sync every 60 seconds to pick up DB changes
    scheduler.add_job(
        sync_schedules,
        trigger=IntervalTrigger(seconds=60),
        id="scheduler_sync",
        name="Scheduler DB Sync",
        replace_existing=True
    )

    # SLA breach checker every hour
    scheduler.add_job(
        check_sla_breaches,
        trigger=IntervalTrigger(hours=1),
        id="sla_breach_check",
        name="SLA Breach Checker",
        replace_existing=True
    )

    # Daily threat intel refresh (EPSS scores change daily, KEV updates frequently)
    scheduler.add_job(
        refresh_threat_intel_enrichment,
        trigger=IntervalTrigger(hours=24),
        id="threat_intel_refresh",
        name="Daily Threat Intel Refresh (EPSS + KEV)",
        replace_existing=True,
        next_run_time=datetime.now() + timedelta(minutes=5),  # First run 5 min after startup
    )

    # Nightly CISA Vulnrichment correction at 03:00 — CISA pushes several
    # commits per day so a daily snapshot keeps CVSS + SSVC fields fresh
    # without spamming the GitHub raw API per-CVE.
    scheduler.add_job(
        refresh_cisa_vulnrichment,
        trigger=CronTrigger(hour=4, minute=30),
        id="vulnrichment_nightly",
        name="Nightly CISA Vulnrichment CVSS / SSVC Correction",
        replace_existing=True,
    )

    # Nightly Wazuh SCA refresh at 02:00 — runs ahead of Vulnrichment so
    # compliance numbers on the dashboard are fresh when admins log in
    # the next morning. Cheap (one summary GET per agent).
    scheduler.add_job(
        refresh_compliance_sca,
        trigger=CronTrigger(hour=2, minute=0),
        id="compliance_sca_nightly",
        name="Nightly Wazuh SCA Compliance Refresh",
        replace_existing=True,
    )

    # Nightly URS recompute at 04:00 — must run AFTER SCA (02:00) and
    # Vulnrichment (03:00) so AVS/ASS inputs reflect today's data.
    # Also prunes asset_risk_snapshots older than 90 days.
    scheduler.add_job(
        recompute_urs_nightly,
        trigger=CronTrigger(hour=5, minute=10),
        id="urs_nightly",
        name="Nightly URS Recompute + Snapshot Prune",
        replace_existing=True,
    )

    # Nightly asset lifecycle reconcile at 04:15 — soft-inactivate
    # assets no source has reported within asset_inactive_after_days
    # (default 30), revive recently-seen ones. Runs after URS.
    scheduler.add_job(
        reconcile_assets_nightly,
        trigger=CronTrigger(hour=5, minute=25),
        id="asset_lifecycle_nightly",
        name="Nightly Asset Lifecycle Reconcile",
        replace_existing=True,
    )

    # Nightly network-exposure refresh at 02:30 — pull syscollector
    # ports, classify risky listeners (VNC/RDP/Telnet/...), store score.
    scheduler.add_job(
        refresh_exposure_nightly,
        trigger=CronTrigger(hour=2, minute=30),
        id="exposure_nightly",
        name="Nightly Network-Exposure Refresh",
        replace_existing=True,
    )

    # Advisory RSS feeds (ZDI/CERT-EU/BSI/...) every 6h at :20.
    scheduler.add_job(
        advisory_feeds_refresh,
        trigger=CronTrigger(hour="*/6", minute=20),
        id="advisory_feeds_refresh",
        name="Security Advisory Feeds Refresh",
        replace_existing=True,
    )

    # New-vuln nightly roundup — runs hourly at :05, self-gates on the
    # configured hour + notification_schedule=='nightly' (see the function).
    scheduler.add_job(
        new_vuln_digest_nightly,
        trigger=CronTrigger(minute=5),
        id="new_vuln_digest_nightly",
        name="Nightly New-Vulnerability Roundup",
        replace_existing=True,
    )

    # Nightly audit-log prune at 03:30 — between Vulnrichment (03:00)
    # and URS (04:00). Retention configurable via setting
    # `audit_log_retention_days` (default 1825 = ~5 years; 0 = keep
    # forever). Covers ISO 27001 / SOX / DSGVO Art.5 windows.
    scheduler.add_job(
        prune_audit_logs_nightly,
        trigger=CronTrigger(hour=5, minute=40),
        id="audit_log_prune_nightly",
        name="Nightly Audit-Log Retention Prune",
        replace_existing=True,
    )

    # Nightly EOL detection via endoflife.date at 03:15 — closes the
    # gap Wazuh has vs Nessus plugin 64784 (unsupported-version
    # detection). Creates pseudo-CVEs (cve_id starts with "EOL-").
    scheduler.add_job(
        eol_check_nightly,
        trigger=CronTrigger(hour=3, minute=0),
        id="eol_check_nightly",
        name="Nightly endoflife.date EOL Detection",
        replace_existing=True,
    )

    # Nightly Microsoft 365 Apps CVE detection (Plan P) at 03:20 — parses
    # the MS365 Apps security-updates page and creates real-CVE rows for
    # builds behind the latest channel patch. Closes the gap where M365
    # fixes never reach NVD / Wazuh.
    scheduler.add_job(
        m365_check_nightly,
        trigger=CronTrigger(hour=3, minute=10),
        id="m365_check_nightly",
        name="Nightly Microsoft 365 Apps CVE Detection",
        replace_existing=True,
    )

    # Nightly exploit-intel refresh (Plan M) at 03:45 — pulls
    # Exploit-DB CSV + PoC-in-GitHub + Metasploit module index, writes
    # per-vuln counts + ref lists.
    scheduler.add_job(
        exploit_intel_nightly,
        trigger=CronTrigger(hour=4, minute=50),
        id="exploit_intel_nightly",
        name="Nightly Public-Exploit Catalog Refresh",
        replace_existing=True,
    )

    # Nightly Microsoft Intune device/inventory sync (02:10 UTC).
    scheduler.add_job(
        intune_sync_nightly,
        trigger=CronTrigger(hour=2, minute=10),
        id="intune_sync_nightly",
        name="Nightly Microsoft Intune Inventory Sync",
        replace_existing=True,
    )

    # Nightly built-in app→CVE scan (03:25 UTC) — maps installed software
    # (Wazuh packages + Intune detectedApps) to real CVEs via OSV/NVD-CPE;
    # closes the coverage gap for Intune-only / mobile devices.
    scheduler.add_job(
        app_cve_scan_nightly,
        trigger=CronTrigger(hour=3, minute=20),
        id="app_cve_scan_nightly",
        name="Nightly Built-in App CVE Scan",
        replace_existing=True,
    )

    # Weekly MSRC CVRF ingest (Sun 04:40 UTC) — per-CVE Microsoft fixes
    # (KB + build + link), workarounds, and mitigations into
    # cve_remediations for the Windows/MS-product findings.
    scheduler.add_job(
        msrc_refresh_weekly,
        trigger=CronTrigger(day_of_week="sun", hour=4, minute=40),
        id="msrc_refresh_weekly",
        name="Weekly MSRC Remediation Enrichment",
        replace_existing=True,
    )

    # Nightly MSRC fixed-build scan (05:10 UTC) — Windows-Server OS CVEs whose
    # FixedBuild is ahead of the host's build. Daily (not weekly) so a Patch
    # Tuesday lands the next morning instead of days later.
    scheduler.add_job(
        msrc_scan_nightly,
        trigger=CronTrigger(hour=3, minute=50),
        id="msrc_scan_nightly",
        name="Nightly MSRC Fixed-Build Scan (Windows OS)",
        replace_existing=True,
    )

    scheduler.start()
    logger.info(
        "Background scheduler started (SLA Breach Checker + Threat Intel Refresh + Vulnrichment Nightly + Compliance SCA Nightly + URS Nightly + Audit-Log Prune)"
    )


def stop_scheduler():
    """Stops the background scheduler"""
    if not HAS_APSCHEDULER or scheduler is None:
        return
    if scheduler.running:
        scheduler.shutdown(wait=False)
        logger.info("Background scheduler stopped")
