"""Worker and monitor loops for brawny daemon.

Provides the main loop functions for intent execution and transaction monitoring.
"""

from __future__ import annotations

import time
from datetime import datetime, timedelta
from threading import Event
from typing import TYPE_CHECKING

from brawny.metrics import (
    ACTIVE_WORKERS,
    BACKGROUND_TASK_ERRORS,
    INTENT_CLAIMED,
    INTENT_RELEASED,
    INTENT_SENDING_STUCK,
    INTENTS_BACKING_OFF,
    CLAIM_RECLAIM_SKIPPED,
    get_metrics,
)
from brawny.model.enums import AttemptStatus, IntentStatus

if TYPE_CHECKING:
    from threading import Thread
    from brawny.daemon.context import DaemonContext, DaemonState

from brawny.alerts.health import cleanup_stale_fingerprints, health_alert


def run_worker(
    worker_id: int,
    stop_event: Event,
    wakeup_hint: Event,
    ctx: "DaemonContext",
    state: "DaemonState",
    dry_run: bool = False,
) -> None:
    """Worker thread for executing intents.

    Args:
        worker_id: Worker identifier for logging
        stop_event: Event signaling shutdown
        wakeup_hint: Event for immediate wakeup on new intents
        ctx: Daemon context with shared components
        state: Daemon state with callbacks
        dry_run: If True, claim and release without executing
    """
    if ctx.executor is None and not dry_run:
        raise RuntimeError("run_worker requires executor unless dry_run")

    ctx.log.debug("worker.started", worker_id=worker_id)

    while not stop_event.is_set():
        if ctx.controls and ctx.controls.is_active("drain_workers"):
            ctx.log.warning("runtime.control.drain_workers", worker_id=worker_id)
            time.sleep(1.0)
            continue
        claim_token = state.make_claim_token(worker_id)
        claimed_by = state.make_claimed_by(worker_id)
        claimed = ctx.db.claim_next_intent(
            claim_token,
            claimed_by=claimed_by,
            lease_seconds=ctx.config.claim_timeout_seconds,
        )

        if claimed is None:
            wakeup_hint.wait(timeout=1.0)
            wakeup_hint.clear()
            continue

        intent = ctx.db.get_intent(claimed.intent_id)
        if intent is None:
            ctx.log.error(
                "worker.claimed_intent_missing",
                intent_id=str(claimed.intent_id),
                claim_token=claimed.claim_token,
                claimed_by=claimed.claimed_by,
                worker_id=worker_id,
            )
            continue

        ctx.log.info(
            "intent.claimed",
            intent_id=str(intent.intent_id),
            job_id=intent.job_id,
            claim_token=claim_token,
            claimed_by=claimed_by,
            worker_id=worker_id,
        )
        metrics = get_metrics()
        metrics.counter(INTENT_CLAIMED).inc(
            chain_id=ctx.chain_id,
        )

        if dry_run:
            ctx.log.info("worker.dry_run", intent_id=str(intent.intent_id))
            released = ctx.db.release_claim_if_token_and_no_attempts(
                intent_id=claimed.intent_id,
                claim_token=claimed.claim_token,
            )
            if not released:
                ctx.log.warning(
                    "worker.dry_run_release_failed",
                    intent_id=str(intent.intent_id),
                )
            else:
                metrics = get_metrics()
                metrics.counter(INTENT_RELEASED).inc(
                    chain_id=ctx.chain_id,
                    reason="dry_run",
                )
            continue

        state.inflight_inc()
        try:
            outcome = ctx.executor.process_claimed_intent(claimed, intent=intent)
            ctx.log.info(
                "worker.executed",
                intent_id=str(intent.intent_id),
                job_id=intent.job_id,
                result=outcome.result.value,
            )
        except Exception as e:
            ctx.log.error(
                "worker.execute_exception",
                intent_id=str(intent.intent_id),
                job_id=intent.job_id,
                error=str(e)[:200],
                exc_info=True,
            )
            health_alert(
                component="brawny.tx.executor",
                chain_id=ctx.chain_id,
                error=e,
                job_id=intent.job_id,
                intent_id=str(intent.intent_id),
                claim_token=claimed.claim_token,
                status=intent.status.value if hasattr(intent.status, "value") else str(intent.status),
                action="Check logs; intent will retry or timeout",
                db_dialect=ctx.db.dialect,
                send_fn=ctx.health_send_fn,
                health_chat_id=ctx.health_chat_id,
                cooldown_seconds=ctx.health_cooldown,
            )
        finally:
            state.inflight_dec()

    ctx.log.debug("worker.stopped", worker_id=worker_id)


def run_monitor(
    stop_event: Event,
    ctx: "DaemonContext",
    worker_threads: list["Thread"],
) -> None:
    """Background loop for monitoring pending transactions.

    Args:
        stop_event: Event signaling shutdown
        ctx: Daemon context with shared components
        worker_threads: List of worker threads for gauge reporting
    """
    if ctx.monitor is None:
        raise RuntimeError("run_monitor requires monitor")
    if ctx.replacer is None:
        raise RuntimeError("run_monitor requires replacer")
    if ctx.nonce_manager is None:
        raise RuntimeError("run_monitor requires nonce_manager")

    ctx.log.debug("monitor.started")
    last_reconcile = time.time()
    last_rpc_health = 0.0
    last_worker_gauge = 0.0
    last_sending_recover = 0.0
    last_log_cleanup = 0.0
    last_claim_reap = 0.0
    last_lease_reclaim = 0.0

    while not stop_event.is_set():
        try:
            ctx.monitor.monitor_all_pending()
            ctx.replacer.process_stuck_transactions()

            now = time.time()
            if now - last_reconcile >= ctx.config.nonce_reconcile_interval_seconds:
                ctx.nonce_manager.reconcile()
                last_reconcile = now

            if now - last_rpc_health >= 30:
                ctx.rpc.get_health()
                last_rpc_health = now

            if now - last_worker_gauge >= 10:
                metrics = get_metrics()
                active = sum(1 for t in worker_threads if t.is_alive())
                metrics.gauge(ACTIVE_WORKERS).set(
                    active,
                    chain_id=ctx.chain_id,
                )
                backing_off = ctx.db.get_backing_off_intent_count(chain_id=ctx.chain_id)
                metrics.gauge(INTENTS_BACKING_OFF).set(
                    backing_off,
                    chain_id=ctx.chain_id,
                )
                last_worker_gauge = now

            if now - last_sending_recover >= 30:
                _recover_stuck_sending(ctx)
                last_sending_recover = now

            if now - last_lease_reclaim >= 30:
                _requeue_expired_claims(ctx)
                last_lease_reclaim = now

            if now - last_claim_reap >= 30:
                _reap_stale_claims(ctx)
                last_claim_reap = now

            # Job log cleanup (hourly)
            if now - last_log_cleanup >= 3600:
                try:
                    from brawny.db.ops import logs as log_ops
                    cutoff = datetime.utcnow() - timedelta(days=ctx.config.log_retention_days)
                    deleted = log_ops.delete_old_logs(ctx.db, ctx.chain_id, cutoff)
                    if deleted > 0:
                        ctx.log.info("job_logs.cleanup", deleted=deleted)
                except Exception as cleanup_err:
                    ctx.log.warning("job_logs.cleanup_failed", error=str(cleanup_err))

                # Health alert fingerprint cleanup (also hourly)
                try:
                    removed = cleanup_stale_fingerprints(ctx.health_cooldown)
                    if removed > 0:
                        ctx.log.debug("health_fingerprints.cleanup", removed=removed)
                except Exception as cleanup_err:
                    ctx.log.warning("health_fingerprints.cleanup_failed", error=str(cleanup_err))

                last_log_cleanup = now
        except Exception as e:
            ctx.log.error("monitor.error", error=str(e)[:200])
            metrics = get_metrics()
            metrics.counter(BACKGROUND_TASK_ERRORS).inc(task="monitor")
            health_alert(
                component="brawny.tx.monitor",
                chain_id=ctx.chain_id,
                error=e,
                action="Check DB/RPC connectivity",
                db_dialect=ctx.db.dialect,
                send_fn=ctx.health_send_fn,
                health_chat_id=ctx.health_chat_id,
                cooldown_seconds=ctx.health_cooldown,
            )

        stop_event.wait(timeout=ctx.config.poll_interval_seconds * 2)

    ctx.log.debug("monitor.stopped")


def _recover_stuck_sending(ctx: "DaemonContext") -> None:
    """Recover intents stuck in SENDING state.

    Args:
        ctx: Daemon context with shared components
    """
    if ctx.nonce_manager is None:
        raise RuntimeError("_recover_stuck_sending requires nonce_manager")

    stuck_sending = ctx.db.list_sending_intents_older_than(
        max_age_seconds=ctx.config.claim_timeout_seconds,
        chain_id=ctx.chain_id,
    )
    for intent in stuck_sending:
        attempt = ctx.db.get_latest_attempt_for_intent(intent.intent_id)
        ctx.db.set_signer_quarantined(
            ctx.chain_id,
            intent.signer_address,
            reason="stuck_sending",
            source="recover_stuck_sending",
        )
        ctx.log.warning(
            "intent.sending_quarantined",
            intent_id=str(intent.intent_id),
            job_id=intent.job_id,
            attempt_id=str(attempt.attempt_id) if attempt else None,
        )
        metrics = get_metrics()
        metrics.counter(INTENT_SENDING_STUCK).inc(
            chain_id=ctx.chain_id,
            age_bucket=">claim_timeout",
        )


def _requeue_expired_claims(ctx: "DaemonContext") -> None:
    grace_seconds = 15
    limit = 50
    requeued = ctx.db.requeue_expired_claims_no_attempts(
        limit=limit,
        grace_seconds=grace_seconds,
        chain_id=ctx.chain_id,
    )
    skipped = ctx.db.count_expired_claims_with_attempts(
        limit=limit,
        grace_seconds=grace_seconds,
        chain_id=ctx.chain_id,
    )
    if requeued == 0 and skipped == 0:
        return
    if requeued > 0:
        ctx.log.info(
            "claim.lease_requeued",
            count=requeued,
        )
        metrics = get_metrics()
        metrics.counter(INTENT_RELEASED).inc(
            requeued,
            chain_id=ctx.chain_id,
            reason="lease_expired",
        )
    if skipped > 0:
        ctx.log.warning(
            "claim.lease_requeue_skipped_with_attempts",
            count=skipped,
        )
        metrics = get_metrics()
        metrics.counter(CLAIM_RECLAIM_SKIPPED).inc(
            skipped,
            chain_id=ctx.chain_id,
        )

    if ctx.config.debug.enable_null_lease_reclaim:
        cutoff_seconds = 15 * 60
        requeued_null = ctx.db.requeue_missing_lease_claims_no_attempts(
            limit=limit,
            cutoff_seconds=cutoff_seconds,
            chain_id=ctx.chain_id,
        )
        skipped_null = ctx.db.count_missing_lease_claims_with_attempts(
            limit=limit,
            cutoff_seconds=cutoff_seconds,
            chain_id=ctx.chain_id,
        )
        if requeued_null > 0:
            ctx.log.warning(
                "claim.null_lease_requeued",
                count=requeued_null,
            )
            metrics = get_metrics()
            metrics.counter(INTENT_RELEASED).inc(
                requeued_null,
                chain_id=ctx.chain_id,
                reason="missing_lease",
            )
        if skipped_null > 0:
            ctx.log.warning(
                "claim.null_lease_requeue_skipped_with_attempts",
                count=skipped_null,
            )
            metrics = get_metrics()
            metrics.counter(CLAIM_RECLAIM_SKIPPED).inc(
                skipped_null,
                chain_id=ctx.chain_id,
            )


def _reap_stale_claims(ctx: "DaemonContext") -> None:
    """Reap stale claimed intents with attempts.

    If a broadcast attempt exists, move to PENDING for monitor/reconcile.
    Otherwise, release claim back to CREATED and mark attempts failed.
    """
    if ctx.nonce_manager is None:
        raise RuntimeError("_reap_stale_claims requires nonce_manager")

    stale = ctx.db.list_claimed_intents_older_than(
        max_age_seconds=ctx.config.claim_timeout_seconds,
        chain_id=ctx.chain_id,
    )
    if not stale:
        return

    ctx.log.warning(
        "claim.reap_detected",
        count=len(stale),
        action="containment_only",
    )
    ctx.db.set_runtime_control(
        control="pause_new_intents",
        active=True,
        expires_at=datetime.utcnow() + timedelta(seconds=300),
        reason="stale_claims_detected",
        actor="reaper",
        mode="auto",
    )
