"""
Asset lifecycle reconciliation.

Tester request: when an asset is decommissioned in the source systems
(removed from Wazuh + Nessus via the org's "system no longer exists"
process), TrueVuln should not keep a data corpse. But hard-deleting
loses the vulnerability history + breaks the revisionssicher audit
trail the operator also asked for.

Compromise: SOFT-inactive. An asset whose `last_scan` is older than
`asset_inactive_after_days` (no source has reported it in that window)
flips to status=INACTIVE. It drops out of the default asset + vuln
views but its rows + audit log survive. If a later sync sees it again
it auto-reactivates to ACTIVE.

Settings:
  asset_inactive_after_days   integer, default 30. 0 = disabled.

Both transitions are audit-logged (user_id=None = automated).
"""
from __future__ import annotations

import logging
from datetime import datetime, timedelta
from typing import Optional

from sqlalchemy.orm import Session

from app.models.asset import Asset, AssetSource, AssetStatus
from app.models.setting import Setting

logger = logging.getLogger(__name__)

_SETTING_KEY = "asset_inactive_after_days"
_DEFAULT_DAYS = 30


def _threshold_days(db: Session) -> int:
    row = db.query(Setting).filter(Setting.key == _SETTING_KEY).first()
    if not row or row.value is None:
        return _DEFAULT_DAYS
    try:
        return int(str(row.value).strip())
    except (ValueError, TypeError):
        return _DEFAULT_DAYS


# Explicit audit-event mapping. Anything not in this dict falls through to
# ASSET_UPDATED. Operators reading the audit log can tell a sync-driven
# deactivation from a manual decommission because the transition shape is
# in the event_type, while the free-form `reason` carries the specifics.
_AUDIT_EVENT_FOR_TRANSITION: dict[tuple[str, str], str] = {
    ("active", "inactive"): "ASSET_DEACTIVATED",
    ("active", "decommissioned"): "ASSET_DEACTIVATED",
    ("inactive", "active"): "ASSET_REACTIVATED",
    ("decommissioned", "active"): "ASSET_REACTIVATED",
}


def _audit_asset_status(db: Session, asset: Asset, old: str, new: str, reason: str) -> None:
    """Automated (user_id=None) asset status change audit entry.

    Uses a dedicated ASSET_DEACTIVATED / ASSET_REACTIVATED event type when
    the transition is active↔inactive (or out of DECOMMISSIONED) so the
    audit UI can filter lifecycle transitions cleanly. Falls back to
    ASSET_UPDATED for every other transition.
    """
    try:
        from app.models.audit_log import AuditLog, AuditEventType
        event_name = _AUDIT_EVENT_FOR_TRANSITION.get((old, new), "ASSET_UPDATED")
        event_type = AuditEventType(event_name)
        db.add(AuditLog(
            user_id=None,
            event_type=event_type,
            event_description=f"Asset {asset.hostname}: status {old} → {new} ({reason})"[:500],
            resource_type="asset",
            resource_id=str(asset.id),
            old_value=old,
            new_value=new,
            timestamp=datetime.now(),
        ))
    except Exception as e:
        logger.warning("asset lifecycle audit failed for %s: %s", asset.id, e)


def reconcile_asset_lifecycle(db: Session) -> dict:
    """Flip stale assets to INACTIVE, revive recently-seen INACTIVE ones.

    Returns {threshold_days, inactivated, reactivated, skipped_disabled}.
    """
    days = _threshold_days(db)
    if days <= 0:
        return {"threshold_days": 0, "inactivated": 0, "reactivated": 0, "skipped_disabled": True}

    cutoff = datetime.now() - timedelta(days=days)
    stats = {"threshold_days": days, "inactivated": 0, "reactivated": 0, "skipped_disabled": False}

    # 1. Active → Inactive: last_scan older than cutoff (or never scanned
    #    AND created before cutoff — a manual asset never synced shouldn't
    #    instantly vanish, but an old never-synced one is a corpse too).
    active = db.query(Asset).filter(Asset.status == AssetStatus.ACTIVE).all()
    for asset in active:
        ref = asset.last_scan or asset.created_at
        if ref is None:
            continue
        if ref < cutoff:
            old = asset.status.value if hasattr(asset.status, "value") else str(asset.status)
            asset.status = AssetStatus.INACTIVE
            _audit_asset_status(
                db, asset, old, "inactive",
                f"not seen in any source for >{days}d (last_scan={asset.last_scan})",
            )
            stats["inactivated"] += 1

    # 2. Inactive → Active: a sync touched last_scan again within window.
    #    (DECOMMISSIONED is operator-final — never auto-revived.)
    inactive = db.query(Asset).filter(Asset.status == AssetStatus.INACTIVE).all()
    for asset in inactive:
        if asset.last_scan and asset.last_scan >= cutoff:
            asset.status = AssetStatus.ACTIVE
            _audit_asset_status(
                db, asset, "inactive", "active",
                "seen again by a source sync",
            )
            stats["reactivated"] += 1

    db.commit()
    logger.info(
        "asset lifecycle reconcile: %d inactivated, %d reactivated (threshold %dd)",
        stats["inactivated"], stats["reactivated"], days,
    )
    return stats


def reconcile_missing_from_sync(
    db: Session,
    *,
    source: AssetSource,
    seen_ids: set,
    id_field,
    reason: str,
) -> dict:
    """Flip ACTIVE assets of `source` whose id is not in `seen_ids` to INACTIVE.

    Event-driven counterpart to the time-based `reconcile_asset_lifecycle()`.
    Use this immediately after a Nessus or Wazuh sync so a host that drops
    out of the latest source result is hidden from the UI within seconds
    rather than waiting for the 30-day window.

    Args:
        source:    AssetSource.WAZUH or AssetSource.NESSUS — only assets of
                   this source are candidates for inactivation.
        seen_ids:  Set of wazuh_agent_id values (Wazuh) or nessus_host_uuid
                   values (Nessus) reported by the just-completed sync.
        id_field:  SQLAlchemy column to match against `seen_ids` —
                   `Asset.wazuh_agent_id` or `Asset.nessus_host_uuid`.
        reason:    Human-readable reason string written to the audit log.

    Returns: {inactivated: int, reactivated: int, scanned: int}.

    Notes:
        - DECOMMISSIONED assets are operator-final and never touched.
        - INACTIVE assets that have re-appeared in `seen_ids` are flipped
          back to ACTIVE (otherwise an asset that was time-deactivated and
          then re-appears in a scan would never come back).
        - The caller is responsible for db.commit(); this function only
          mutates the session.
    """
    stats = {"inactivated": 0, "reactivated": 0, "scanned": 0}

    # Fail-open guard: if the upstream returned an empty set, we cannot
    # distinguish "the sync ran and saw zero hosts" from "the upstream
    # API failed and returned nothing" — both would otherwise deactivate
    # every ACTIVE asset of this source. Skip + log loudly. Operators
    # seeing this WARNING should investigate the upstream.
    if seen_ids is not None and len(seen_ids) == 0:
        logger.warning(
            "asset sync-reconcile (%s): skipped — seen_ids is empty "
            "(upstream returned no hosts? not deactivating anything)",
            source.value if hasattr(source, "value") else source,
        )
        return stats

    # ---- Inactivation: ACTIVE assets of `source` whose id is not in seen_ids.
    if seen_ids is not None:
        # One-time backfill: re-tag MANUAL assets that have a non-empty
        # nessus_host_uuid to NESSUS so they participate in the per-source
        # reconcile below. Pre-existing rows predate the NESSUS enum value.
        # Done Python-side (fetch + ORM mutate) so the session stays in
        # sync — a bulk .update() with synchronize_session=False desyncs
        # any pre-loaded Asset instances in this session.
        if source == AssetSource.NESSUS:
            from sqlalchemy import func as _sa_func
            stale = (
                db.query(Asset)
                .filter(
                    Asset.source == AssetSource.MANUAL,
                    Asset.nessus_host_uuid.isnot(None),
                    _sa_func.length(Asset.nessus_host_uuid) > 0,
                )
                .all()
            )
            for a in stale:
                a.source = AssetSource.NESSUS

        # Diagnostic: log how many candidates we're about to evaluate so
        # a tester reporting "INACTIVE never flips" can paste this line
        # in the bug report — it tells us if the issue is upstream (no
        # nessus_host_uuid pinned) or downstream (reconcile logic).
        legacy_unpinned = (
            db.query(Asset)
            .filter(
                Asset.source == source,
                Asset.status == AssetStatus.ACTIVE,
                id_field.is_(None),
            )
            .count()
        )
        if legacy_unpinned:
            logger.info(
                "asset sync-reconcile (%s): %d ACTIVE assets have no %s pinned "
                "— they will be skipped by the per-id reconcile. Consider a "
                "host-name backfill job to set nessus_host_uuid for legacy rows.",
                source.value if hasattr(source, "value") else source,
                legacy_unpinned, id_field.key,
            )

        active_q = (
            db.query(Asset)
            .filter(
                Asset.source == source,
                Asset.status == AssetStatus.ACTIVE,
                id_field.isnot(None),
                ~id_field.in_(seen_ids),
            )
        )
        active_candidates = active_q.all()
        stats["scanned"] = len(active_candidates)
        for asset in active_candidates:
            old = asset.status.value if hasattr(asset.status, "value") else str(asset.status)
            asset.status = AssetStatus.INACTIVE
            _audit_asset_status(db, asset, old, "inactive", reason)
            stats["inactivated"] += 1

        # ---- Reactivation: INACTIVE assets of `source` that re-appeared.
        inactive_q = (
            db.query(Asset)
            .filter(
                Asset.source == source,
                Asset.status == AssetStatus.INACTIVE,
                id_field.isnot(None),
                id_field.in_(seen_ids),
            )
        )
        for asset in inactive_q.all():
            asset.status = AssetStatus.ACTIVE
            _audit_asset_status(
                db, asset, "inactive", "active",
                "seen again by a source sync (event-driven revive)",
            )
            stats["reactivated"] += 1

    if stats["inactivated"] or stats["reactivated"]:
        logger.info(
            "asset sync-reconcile (%s): %d inactivated, %d reactivated",
            source.value if hasattr(source, "value") else source,
            stats["inactivated"], stats["reactivated"],
        )
    return stats


def reconcile_nessus_by_seen_ids(
    db: Session,
    *,
    seen_asset_ids: set,
    reason: str,
) -> dict:
    """Robust id-keyed Nessus reconcile — supersedes the uuid-keyed path.

    Why id-keyed: the previous reconcile keyed on `nessus_host_uuid`. If
    the only host in a reduced-scope scan had no `host_uuid` in its
    Nessus host_info, `seen_uuids` came back EMPTY → the fail-open guard
    skipped everything → the dropped hosts stayed ACTIVE (tester bug).
    Tracking the matched `asset.id` of every host actually touched this
    sync avoids that: even a uuid-less host still contributes its id, so
    the seen-set is non-empty and the dropped assets get inactivated.

    Candidate set = ACTIVE assets Nessus knows about, i.e.
      source == NESSUS  OR  nessus_host_uuid IS NOT NULL
    minus the ids seen this run.

    Fail-open: empty seen set → skip (can't tell "scan saw nothing" from
    "upstream failed"). DECOMMISSIONED is operator-final, never touched.
    Caller commits.
    """
    from sqlalchemy import or_ as _or
    stats = {"inactivated": 0, "reactivated": 0, "candidates": 0}

    if not seen_asset_ids:
        logger.warning(
            "nessus reconcile (id-keyed): skipped — seen_asset_ids empty "
            "(scan returned no matched hosts? not deactivating anything)"
        )
        return stats

    nessus_known = _or(
        Asset.source == AssetSource.NESSUS,
        Asset.nessus_host_uuid.isnot(None),
    )

    # Inactivate: Nessus-known, ACTIVE, not seen this run.
    candidates = (
        db.query(Asset)
        .filter(
            nessus_known,
            Asset.status == AssetStatus.ACTIVE,
            ~Asset.id.in_(seen_asset_ids),
        )
        .all()
    )
    stats["candidates"] = len(candidates)
    for asset in candidates:
        old = asset.status.value if hasattr(asset.status, "value") else str(asset.status)
        asset.status = AssetStatus.INACTIVE
        _audit_asset_status(db, asset, old, "inactive", reason)
        stats["inactivated"] += 1

    # Reactivate: Nessus-known, INACTIVE, seen again this run.
    revived = (
        db.query(Asset)
        .filter(
            nessus_known,
            Asset.status == AssetStatus.INACTIVE,
            Asset.id.in_(seen_asset_ids),
        )
        .all()
    )
    for asset in revived:
        asset.status = AssetStatus.ACTIVE
        _audit_asset_status(
            db, asset, "inactive", "active",
            "seen again by a Nessus sync (event-driven revive)",
        )
        stats["reactivated"] += 1

    return stats


def reconcile_intune_by_seen_ids(
    db: Session,
    *,
    seen_asset_ids: set,
    reason: str,
) -> dict:
    """Id-keyed Intune reconcile — same robust pattern as the Nessus one.

    Candidate set = ACTIVE Intune-known assets (source == INTUNE OR
    intune_device_id IS NOT NULL) minus the ids seen this Graph sync →
    INACTIVE; INACTIVE ones seen again → ACTIVE. Fail-open on empty seen
    set. DECOMMISSIONED untouched. Caller commits.
    """
    from sqlalchemy import or_ as _or
    stats = {"inactivated": 0, "reactivated": 0, "candidates": 0}

    if not seen_asset_ids:
        logger.warning(
            "intune reconcile: skipped — seen_asset_ids empty "
            "(sync returned no matched devices? not deactivating anything)"
        )
        return stats

    intune_known = _or(
        Asset.source == AssetSource.INTUNE,
        Asset.intune_device_id.isnot(None),
    )

    candidates = (
        db.query(Asset)
        .filter(
            intune_known,
            Asset.status == AssetStatus.ACTIVE,
            ~Asset.id.in_(seen_asset_ids),
        )
        .all()
    )
    stats["candidates"] = len(candidates)
    for asset in candidates:
        old = asset.status.value if hasattr(asset.status, "value") else str(asset.status)
        asset.status = AssetStatus.INACTIVE
        _audit_asset_status(db, asset, old, "inactive", reason)
        stats["inactivated"] += 1

    revived = (
        db.query(Asset)
        .filter(
            intune_known,
            Asset.status == AssetStatus.INACTIVE,
            Asset.id.in_(seen_asset_ids),
        )
        .all()
    )
    for asset in revived:
        asset.status = AssetStatus.ACTIVE
        _audit_asset_status(
            db, asset, "inactive", "active",
            "seen again by an Intune sync (event-driven revive)",
        )
        stats["reactivated"] += 1

    return stats
