# Copyright (c) 2023-present Pi Dash Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

"""Tests for ``pi_dash.bgtasks.scheduler``.

Covers the three-phase claim/dispatch/rollback flow in
``fire_scheduler_binding`` plus the scanner's filter semantics.
"""

from __future__ import annotations

from datetime import timedelta
from unittest import mock

import pytest
from crum import impersonate
from django.utils import timezone

from pi_dash.bgtasks.scheduler import (
    fire_scheduler_binding,
    scan_due_bindings,
)
from pi_dash.db.models import Project, Scheduler, SchedulerBinding
from pi_dash.db.models.scheduler import OutcomeMode, outcome_mode_directive
from pi_dash.runner.models import AgentRun, AgentRunStatus, Pod, Runner, RunnerStatus


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def project(db, workspace, create_user):
    with impersonate(create_user):
        return Project.objects.create(
            name="Web",
            identifier="WEB",
            workspace=workspace,
            created_by=create_user,
        )


@pytest.fixture
def runner_for_workspace(db, workspace, project, create_user):
    # Pods became project-scoped in #77 — there is no longer a
    # ``Pod.default_for_workspace`` accessor. The binding fixture below
    # takes ``project`` already, so resolve the default pod through it.
    pod = Pod.default_for_project(project)
    # ``credential_hash`` / ``credential_fingerprint`` were renamed to
    # ``refresh_token_hash`` / ``refresh_token_fingerprint`` in the
    # per-runner HTTPS transport rollout. Keep this fixture's intent
    # (an active runner with credential state populated) but use the
    # current field names.
    return Runner.objects.create(
        owner=create_user,
        workspace=workspace,
        pod=pod,
        name="agentA",
        refresh_token_hash="h",
        refresh_token_fingerprint="f" * 12,
        status=RunnerStatus.ONLINE,
        last_heartbeat_at=timezone.now(),
    )


@pytest.fixture(autouse=True)
def stub_drain(monkeypatch):
    """Force `transaction.on_commit` to fire its callback synchronously
    and stub `drain_pod_by_id` so tests don't try to assign work to a
    real runner."""
    from pi_dash.runner.services import matcher

    drain_mock = mock.Mock()
    monkeypatch.setattr(matcher, "drain_pod_by_id", drain_mock)
    monkeypatch.setattr(
        "django.db.transaction.on_commit",
        lambda fn, **kw: fn(),
    )
    return drain_mock


@pytest.fixture
def scheduler(workspace, create_user):
    # Use a slug that does NOT collide with the builtins seeded by the
    # ``post_save(Workspace)`` signal in ``pi_dash.scheduler.signals``. See
    # ``test_scheduler.py::scheduler`` for the full rationale.
    with impersonate(create_user):
        return Scheduler.objects.create(
            workspace=workspace,
            slug="test-scheduler",
            name="Test Scheduler",
            description="Test",
            prompt="Scan the project.",
        )


@pytest.fixture
def binding(scheduler, project, workspace, create_user, runner_for_workspace):
    """A binding due immediately (next_run_at = NULL) with an RRULE that fires every minute."""
    with impersonate(create_user):
        return SchedulerBinding.objects.create(
            scheduler=scheduler,
            project=project,
            workspace=workspace,
            dtstart=timezone.now() - timedelta(days=1),
            rrule="FREQ=MINUTELY",
            tzid="UTC",
            extra_context="",
            enabled=True,
            actor=create_user,
        )


# ---------------------------------------------------------------------------
# scan_due_bindings
# ---------------------------------------------------------------------------


@pytest.mark.unit
def test_scan_picks_up_due_bindings(binding):
    # binding has next_run_at=None → due
    count = scan_due_bindings.__wrapped__()
    assert count == 1


@pytest.mark.unit
def test_scan_skips_disabled_bindings(binding):
    binding.enabled = False
    binding.save(update_fields=["enabled"])
    count = scan_due_bindings.__wrapped__()
    assert count == 0


@pytest.mark.unit
def test_scan_skips_bindings_whose_scheduler_is_disabled(binding, scheduler):
    scheduler.is_enabled = False
    scheduler.save(update_fields=["is_enabled"])
    count = scan_due_bindings.__wrapped__()
    assert count == 0


@pytest.mark.unit
def test_scan_skips_soft_deleted_scheduler(binding, scheduler):
    """Codex review #4: soft-deleted scheduler's bindings must not fire
    even if cascade soft-delete hasn't run yet."""
    # Soft-delete the scheduler directly (skip the cascade so the binding
    # row still has deleted_at IS NULL, simulating the async-cascade window).
    Scheduler.all_objects.filter(pk=scheduler.pk).update(deleted_at=timezone.now())
    count = scan_due_bindings.__wrapped__()
    assert count == 0


@pytest.mark.unit
def test_scan_respects_future_next_run_at(binding):
    binding.next_run_at = timezone.now() + timedelta(hours=1)
    binding.save(update_fields=["next_run_at"])
    count = scan_due_bindings.__wrapped__()
    assert count == 0


# ---------------------------------------------------------------------------
# fire_scheduler_binding — happy path
# ---------------------------------------------------------------------------


@pytest.mark.unit
def test_fire_creates_run_and_advances_next_run_at(binding):
    fired = fire_scheduler_binding(str(binding.pk))
    assert fired is True
    binding.refresh_from_db()
    assert binding.last_run_id is not None
    assert binding.last_run.status == AgentRunStatus.QUEUED
    assert binding.last_run.scheduler_binding_id == binding.pk
    assert binding.last_run.work_item_id is None
    assert binding.next_run_at is not None
    assert binding.next_run_at > timezone.now()
    assert binding.last_error == ""


@pytest.mark.unit
def test_fire_resolves_prompt_with_extra_context(scheduler, binding):
    binding.extra_context = "Focus on authn paths."
    binding.save(update_fields=["extra_context"])
    fire_scheduler_binding(str(binding.pk))
    binding.refresh_from_db()
    run = binding.last_run
    assert "Scan the project." in run.prompt
    assert "Focus on authn paths." in run.prompt


# ---------------------------------------------------------------------------
# fire_scheduler_binding — pod resolution (binding.pod override)
# ---------------------------------------------------------------------------


@pytest.mark.unit
def test_fire_uses_default_pod_when_binding_pod_unset(binding, project):
    """No override → run lands on the project's default pod (prior behavior)."""
    default_pod = Pod.default_for_project(project)
    fire_scheduler_binding(str(binding.pk))
    binding.refresh_from_db()
    assert binding.last_run.pod_id == default_pod.id


@pytest.mark.unit
def test_fire_uses_binding_pod_override(binding, project, create_user):
    """An explicit, active, same-project pod override is honored."""
    with impersonate(create_user):
        custom = Pod.objects.create(
            project=project, name=f"{project.identifier}_custom", created_by=create_user
        )
    binding.pod = custom
    binding.save(update_fields=["pod"])
    fire_scheduler_binding(str(binding.pk))
    binding.refresh_from_db()
    assert binding.last_run.pod_id == custom.id


@pytest.mark.unit
def test_fire_falls_back_to_default_when_override_pod_soft_deleted(binding, project, create_user):
    """A soft-deleted override pod degrades to the project default rather
    than dispatching into a dead pod."""
    default_pod = Pod.default_for_project(project)
    with impersonate(create_user):
        custom = Pod.objects.create(
            project=project, name=f"{project.identifier}_dead", created_by=create_user
        )
    binding.pod = custom
    binding.save(update_fields=["pod"])
    custom.deleted_at = timezone.now()
    custom.save(update_fields=["deleted_at"])
    fire_scheduler_binding(str(binding.pk))
    binding.refresh_from_db()
    assert binding.last_run.pod_id == default_pod.id


# ---------------------------------------------------------------------------
# fire_scheduler_binding — outcome_mode work-mode directive
# ---------------------------------------------------------------------------


@pytest.mark.unit
def test_fire_appends_create_issue_directive_by_default(binding):
    """A scheduler with the default outcome_mode (create_issue) appends the
    create-issue directive after the task prompt."""
    fire_scheduler_binding(str(binding.pk))
    binding.refresh_from_db()
    prompt = binding.last_run.prompt
    assert "Work mode: create issues" in prompt
    assert "pidash issue create" in prompt
    # Task prompt comes first; directive is appended after it.
    assert prompt.index("Scan the project.") < prompt.index("Work mode: create issues")


@pytest.mark.unit
def test_fire_appends_apply_fix_directive(binding):
    binding.outcome_mode = OutcomeMode.APPLY_FIX
    binding.save(update_fields=["outcome_mode"])
    fire_scheduler_binding(str(binding.pk))
    binding.refresh_from_db()
    prompt = binding.last_run.prompt
    assert "Work mode: apply fix" in prompt
    assert "open a pull request" in prompt
    assert "do NOT merge" in prompt
    # The other modes' directives must not leak in.
    assert "Work mode: create issues" not in prompt
    assert "Work mode: fix and open for review" not in prompt


@pytest.mark.unit
def test_fire_appends_fix_and_review_directive(binding):
    """fix_and_review files an issue, applies the fix, and moves the issue
    straight to In Review — the directive must instruct all three steps."""
    binding.outcome_mode = OutcomeMode.FIX_AND_REVIEW
    binding.save(update_fields=["outcome_mode"])
    fire_scheduler_binding(str(binding.pk))
    binding.refresh_from_db()
    prompt = binding.last_run.prompt
    assert "Work mode: fix and open for review" in prompt
    assert "pidash issue create" in prompt
    assert "open a pull request" in prompt
    assert 'pidash issue patch <IDENT> --state "In Review"' in prompt


@pytest.mark.unit
def test_outcome_mode_directive_falls_back_for_unknown_value():
    """A stale / unrecognized mode never yields an empty directive — it falls
    back to the create-issue guidance so a run is never dispatched without a
    work-mode instruction."""
    assert "Work mode: create issues" in outcome_mode_directive("nonexistent_mode")


@pytest.mark.unit
def test_fire_phase3a_save_advances_updated_at(binding):
    """Codex review #6: Phase 3a must use save() so auto_now fires."""
    before = binding.updated_at
    fire_scheduler_binding(str(binding.pk))
    binding.refresh_from_db()
    assert binding.updated_at > before


# ---------------------------------------------------------------------------
# fire_scheduler_binding — skip / rollback paths
# ---------------------------------------------------------------------------


@pytest.mark.unit
def test_fire_skips_when_last_run_in_flight(binding, workspace, project, create_user):
    """Concurrency policy: a non-terminal previous run blocks the next tick."""
    pod = Pod.default_for_project(project)
    in_flight = AgentRun.objects.create(
        workspace=workspace,
        created_by=create_user,
        pod=pod,
        scheduler_binding=binding,
        status=AgentRunStatus.RUNNING,
        prompt="prior tick",
    )
    binding.last_run = in_flight
    prior_next_run_at = timezone.now() - timedelta(seconds=5)
    binding.next_run_at = prior_next_run_at
    binding.save(update_fields=["last_run", "next_run_at"])

    fired = fire_scheduler_binding(str(binding.pk))
    assert fired is False
    binding.refresh_from_db()
    # next_run_at must NOT have advanced — skip is silent
    assert binding.next_run_at == prior_next_run_at


@pytest.mark.unit
def test_fire_disables_binding_with_bad_rrule_and_clears_next_run_at(binding):
    """Bad RRULE bundle disable must also clear next_run_at (parallels the
    legacy bad-cron handler — Codex review #10)."""
    binding.rrule = "this is not a real rrule"
    binding.next_run_at = timezone.now() - timedelta(seconds=5)
    binding.save(update_fields=["rrule", "next_run_at"])

    fired = fire_scheduler_binding(str(binding.pk))
    assert fired is False
    binding.refresh_from_db()
    assert binding.enabled is False
    assert binding.next_run_at is None
    assert "invalid rrule" in binding.last_error


@pytest.mark.unit
def test_fire_rolls_back_next_run_at_on_dispatch_failure(monkeypatch, binding):
    """Phase 3b: when dispatch returns None, restore prior next_run_at
    and surface the specific reason on ``last_error``."""
    binding.next_run_at = None  # NULL — true "first run, due now"
    binding.save(update_fields=["next_run_at"])

    monkeypatch.setattr(
        "pi_dash.orchestration.service.dispatch_scheduler_run",
        lambda b, p: (None, "no default pod for workspace test"),
    )
    fired = fire_scheduler_binding(str(binding.pk))
    assert fired is False
    binding.refresh_from_db()
    # Rolled back to NULL — no scheduled time advanced since dispatch failed
    assert binding.next_run_at is None
    assert "dispatch failed" in binding.last_error
    # Specific reason surfaced through (Codex review: avoid lossy errors)
    assert "no default pod" in binding.last_error


@pytest.mark.unit
def test_fire_returns_false_when_binding_deleted(binding):
    binding.delete()  # soft-delete
    fired = fire_scheduler_binding(str(binding.pk))
    assert fired is False
