# -*- coding: utf-8 -*-
"""Integration tests for the cron header (default-agent) endpoints.

The cron router is mounted twice — once under ``/api/agents/{agentId}/cron``
(scoped, covered by test_cron.py) and once under ``/api/cron`` (header,
covered here). Both share the same code path; the header tests verify
the default-agent resolution route (``get_agent_for_request`` →
default agent's CronManager) works end-to-end.

Job state lives on disk:
  - jobs.json:   <working_dir>/workspaces/default/jobs.json
  - history:     <working_dir>/workspaces/default/jobs_history/<job_id>.json

Most cases seed jobs through the HTTP API itself (so we exercise the
real write path). The history case seeds the history file directly
because the CronExecutor would need a real LLM round-trip to produce
records — that's Sprint 2.4's job. The manager caches history on
first read, so we seed BEFORE the first GET history call.
"""
from __future__ import annotations

import json
import time
from pathlib import Path
from typing import Any

import pytest
from helpers import default_http_timeout

_CRON_HTTP_TIMEOUT = default_http_timeout(15.0)


# --------------------------------------------------------------------------- #
# helpers
# --------------------------------------------------------------------------- #


def _minimal_text_cron_spec(*, name: str, user_id: str | None = None) -> dict:
    """Build a valid CronJobSpec with task_type=text (no agent run)."""
    return {
        "name": name,
        "enabled": True,
        "schedule": {"type": "cron", "cron": "0 0 * * *", "timezone": "UTC"},
        "task_type": "text",
        "text": f"integration cron noop: {name}",
        "dispatch": {
            "type": "channel",
            "channel": "console",
            "target": {
                "user_id": user_id or "integ-cron-header-user",
                "session_id": (
                    "console:integ-cron-header-"
                    + (user_id or "default")
                    + "-session"
                ),
            },
            "mode": "stream",
            "meta": {},
        },
    }


def _default_workspace_dir(app_server) -> Path:
    """Resolve the default agent's workspace dir on disk.

    Matches the layout set up by the app startup migration:
    ``<working_dir>/workspaces/default``.
    """
    return app_server.working_dir / "workspaces" / "default"


def _create_cron_job(app_server, spec: dict) -> str:
    """POST /api/cron/jobs and return the new server-assigned job id."""
    resp = app_server.api_request(
        "POST",
        "/api/cron/jobs",
        json=spec,
        timeout=_CRON_HTTP_TIMEOUT,
    )
    assert resp.status_code == 200, app_server.logs_tail()
    job_id = resp.json().get("id")
    assert isinstance(job_id, str) and job_id
    return job_id


def _delete_cron_job_quietly(app_server, job_id: str) -> None:
    """Best-effort delete used by finally blocks."""
    app_server.api_request(
        "DELETE",
        f"/api/cron/jobs/{job_id}",
        timeout=_CRON_HTTP_TIMEOUT,
    )


def _seed_history_records(
    app_server,
    job_id: str,
    records: list[dict[str, Any]],
) -> None:
    """Write seeded CronExecutionRecord list to the job's history file.

    Must be called BEFORE the first GET /api/cron/jobs/{id}/history,
    otherwise CronManager will have already cached an empty list.
    """
    history_dir = _default_workspace_dir(app_server) / "jobs_history"
    history_dir.mkdir(parents=True, exist_ok=True)
    history_file = history_dir / f"{job_id}.json"
    history_file.write_text(
        json.dumps(records, ensure_ascii=False),
        encoding="utf-8",
    )


# --------------------------------------------------------------------------- #
# cases
# --------------------------------------------------------------------------- #


@pytest.mark.integration
@pytest.mark.p1
def test_cron_header_jobs_list_returns_empty_array_contract(
    app_server,
) -> None:
    """Test purpose:
    - Verify GET /api/cron/jobs returns [] on a fresh workspace. This
      is the first read Console's 定时任务 page makes; a 5xx here
      blocks the entire cron UI from loading.

    Test flow:
    1. GET /api/cron/jobs (no jobs created yet — but other tests in
       this module may have left some, so just assert ``isinstance
       list`` rather than strict empty).
    2. Assert 200 + response is a list.

    API endpoints:
    - GET /api/cron/jobs
    """
    resp = app_server.api_request(
        "GET",
        "/api/cron/jobs",
        timeout=_CRON_HTTP_TIMEOUT,
    )
    assert resp.status_code == 200, app_server.logs_tail()
    assert isinstance(resp.json(), list)


@pytest.mark.integration
@pytest.mark.p1
def test_cron_header_jobs_create_three_then_list_returns_all(
    app_server,
) -> None:
    """Test purpose:
    - Verify the header list endpoint reflects every job that was
      created via the header POST endpoint, with name + id intact.
      This exercises real data (3 distinct jobs) rather than the empty
      contract.

    Test flow:
    1. POST /api/cron/jobs three times with distinct names + targets.
    2. GET /api/cron/jobs and check all 3 ids/names appear with their
       correct schedule.
    3. finally — delete all 3.

    API endpoints:
    - POST /api/cron/jobs
    - GET /api/cron/jobs
    - DELETE /api/cron/jobs/{job_id}
    """
    job_ids: list[str] = []
    expected = [
        ("integ header cron A", "alice"),
        ("integ header cron B", "bob"),
        ("integ header cron C", "carol"),
    ]
    try:
        for name, user in expected:
            spec = _minimal_text_cron_spec(name=name, user_id=user)
            job_ids.append(_create_cron_job(app_server, spec))

        list_resp = app_server.api_request(
            "GET",
            "/api/cron/jobs",
            timeout=_CRON_HTTP_TIMEOUT,
        )
        assert list_resp.status_code == 200, app_server.logs_tail()
        items = list_resp.json()
        listed_ids = {item.get("id") for item in items}
        for job_id in job_ids:
            assert job_id in listed_ids, f"missing {job_id} in {listed_ids}"

        by_id = {
            item["id"]: item for item in items if item.get("id") in job_ids
        }
        for job_id, (name, _user) in zip(job_ids, expected):
            assert by_id[job_id].get("name") == name
            assert by_id[job_id].get("enabled") is True
    finally:
        for job_id in job_ids:
            _delete_cron_job_quietly(app_server, job_id)


@pytest.mark.integration
@pytest.mark.p2
def test_cron_header_get_job_returns_404_for_missing(app_server) -> None:
    """Test purpose:
    - Verify GET /api/cron/jobs/<missing> returns 404 + "job not found".

    Test flow:
    1. GET /api/cron/jobs/integ-missing-job-id-0001.
    2. Assert 404 + detail == "job not found".

    API endpoints:
    - GET /api/cron/jobs/{job_id}
    """
    resp = app_server.api_request(
        "GET",
        "/api/cron/jobs/integ-missing-cron-header-0001",
        timeout=_CRON_HTTP_TIMEOUT,
    )
    assert resp.status_code == 404, app_server.logs_tail()
    assert resp.json().get("detail") == "job not found"


@pytest.mark.integration
@pytest.mark.p1
def test_cron_header_dispatch_targets_returns_contract(app_server) -> None:
    """Test purpose:
    - Verify GET /api/cron/dispatch-targets returns the documented
      ``{channels: [...], items: [...]}`` shape with ``console``
      always present in the channels list (the server unconditionally
      surfaces console as a fallback channel).

    Test flow:
    1. GET /api/cron/dispatch-targets.
    2. Assert 200, response is a dict, ``channels`` is a list that
       contains ``console``, and ``items`` is a list.

    API endpoints:
    - GET /api/cron/dispatch-targets
    """
    resp = app_server.api_request(
        "GET",
        "/api/cron/dispatch-targets",
        timeout=_CRON_HTTP_TIMEOUT,
    )
    assert resp.status_code == 200, app_server.logs_tail()
    payload = resp.json()
    assert isinstance(payload, dict)
    channels = payload.get("channels")
    assert isinstance(channels, list)
    assert "console" in channels
    assert isinstance(payload.get("items"), list)


@pytest.mark.integration
@pytest.mark.p0
def test_cron_header_jobs_lifecycle_via_default_agent(app_server) -> None:
    """Test purpose:
    - Exercise the full header cron lifecycle (create → get → put →
      pause → resume → state → manual run trigger → delete → 404) so
      the default-agent resolution path is verified end-to-end. The
      scoped equivalent is already covered by
      ``test_agent_scoped_cron_job_lifecycle``; both paths share the
      same code but resolve the workspace differently.

    Test flow:
    1. POST /api/cron/jobs → capture job_id.
    2. GET /api/cron/jobs/{id} → assert detail matches.
    3. PUT /api/cron/jobs/{id} → rename; assert new name read back.
    4. POST .../pause → assert 200; GET .../state → enabled=false.
    5. POST .../resume → assert 200; GET .../state → enabled=true.
    6. POST .../run → assert 200 (manual run accepted; executor runs
       async with a noop text payload).
    7. DELETE → assert 200; repeat DELETE → 404 "job not found".

    API endpoints:
    - POST /api/cron/jobs
    - GET /api/cron/jobs/{job_id}
    - PUT /api/cron/jobs/{job_id}
    - POST /api/cron/jobs/{job_id}/pause
    - POST /api/cron/jobs/{job_id}/resume
    - POST /api/cron/jobs/{job_id}/run
    - GET /api/cron/jobs/{job_id}/state
    - DELETE /api/cron/jobs/{job_id}
    """
    spec_v1 = _minimal_text_cron_spec(name="integ header lifecycle v1")
    job_id = _create_cron_job(app_server, spec_v1)

    try:
        get_resp = app_server.api_request(
            "GET",
            f"/api/cron/jobs/{job_id}",
            timeout=_CRON_HTTP_TIMEOUT,
        )
        assert get_resp.status_code == 200, app_server.logs_tail()
        view = get_resp.json()
        assert view.get("spec", {}).get("id") == job_id
        assert view.get("spec", {}).get("name") == "integ header lifecycle v1"

        spec_v2 = dict(spec_v1)
        spec_v2["name"] = "integ header lifecycle v2"
        spec_v2["id"] = job_id
        put_resp = app_server.api_request(
            "PUT",
            f"/api/cron/jobs/{job_id}",
            json=spec_v2,
            timeout=_CRON_HTTP_TIMEOUT,
        )
        assert put_resp.status_code == 200, app_server.logs_tail()
        assert put_resp.json().get("name") == "integ header lifecycle v2"

        pause_resp = app_server.api_request(
            "POST",
            f"/api/cron/jobs/{job_id}/pause",
            timeout=_CRON_HTTP_TIMEOUT,
        )
        assert pause_resp.status_code == 200, app_server.logs_tail()
        # GET state right after pause: CronJobState currently does not
        # expose a ``paused`` boolean (pause_job only touches the
        # internal APScheduler state, not the persisted spec), so we
        # can only assert the endpoint contract holds and that
        # ``next_run_at`` is reachable. Strict pause-state assertion
        # is gated on the server exposing a state.paused field.
        state_after_pause = app_server.api_request(
            "GET",
            f"/api/cron/jobs/{job_id}/state",
            timeout=_CRON_HTTP_TIMEOUT,
        )
        assert state_after_pause.status_code == 200, app_server.logs_tail()
        paused_state = state_after_pause.json()
        assert isinstance(paused_state, dict)
        assert "next_run_at" in paused_state

        resume_resp = app_server.api_request(
            "POST",
            f"/api/cron/jobs/{job_id}/resume",
            timeout=_CRON_HTTP_TIMEOUT,
        )
        assert resume_resp.status_code == 200, app_server.logs_tail()
        # GET state right after resume: same API-limitation caveat as
        # above. Assert the contract still holds (so the resume call
        # didn't corrupt state) and the state object is still well-formed.
        state_after_resume = app_server.api_request(
            "GET",
            f"/api/cron/jobs/{job_id}/state",
            timeout=_CRON_HTTP_TIMEOUT,
        )
        assert state_after_resume.status_code == 200, app_server.logs_tail()
        resumed_state = state_after_resume.json()
        assert isinstance(resumed_state, dict)
        assert "next_run_at" in resumed_state

        run_resp = app_server.api_request(
            "POST",
            f"/api/cron/jobs/{job_id}/run",
            timeout=_CRON_HTTP_TIMEOUT,
        )
        assert run_resp.status_code == 200, app_server.logs_tail()

        delete_resp = app_server.api_request(
            "DELETE",
            f"/api/cron/jobs/{job_id}",
            timeout=_CRON_HTTP_TIMEOUT,
        )
        assert delete_resp.status_code == 200, app_server.logs_tail()
        job_id = None  # avoid double-delete in finally

        repeat_delete = app_server.api_request(
            "DELETE",
            f"/api/cron/jobs/{view['spec']['id']}",
            timeout=_CRON_HTTP_TIMEOUT,
        )
        assert repeat_delete.status_code == 404, app_server.logs_tail()
    finally:
        if job_id:
            _delete_cron_job_quietly(app_server, job_id)


@pytest.mark.integration
@pytest.mark.p1
def test_cron_header_job_history_with_seeded_records_returns_chronological(
    app_server,
) -> None:
    """Test purpose:
    - Verify GET /api/cron/jobs/{id}/history returns seeded records
      with their schema fields intact (run_at, status, error, trigger).
      Because executing a job needs a real LLM round-trip (deferred
      to Sprint 2.4), we seed history.json directly under the default
      workspace and call GET before the manager caches anything for
      this job_id.

    Test flow:
    1. Create a new job; capture job_id.
    2. Write 3 CronExecutionRecord entries to
       <ws>/jobs_history/<job_id>.json (2 success + 1 error, distinct
       run_at timestamps).
    3. GET /api/cron/jobs/{id}/history.
    4. Assert 200 + length 3 + status/error/trigger values match the
       seeded records, in the order they were written.
    5. finally — delete the job.

    API endpoints:
    - POST /api/cron/jobs
    - GET /api/cron/jobs/{job_id}/history
    - DELETE /api/cron/jobs/{job_id}
    """
    spec = _minimal_text_cron_spec(name="integ header history seed")
    job_id = _create_cron_job(app_server, spec)
    try:
        seeded: list[dict[str, Any]] = [
            {
                "run_at": "2026-05-26T08:00:00+00:00",
                "status": "success",
                "error": None,
                "trigger": "scheduled",
            },
            {
                "run_at": "2026-05-26T10:00:00+00:00",
                "status": "error",
                "error": "integration seeded failure",
                "trigger": "manual",
            },
            {
                "run_at": "2026-05-26T12:00:00+00:00",
                "status": "success",
                "error": None,
                "trigger": "scheduled",
            },
        ]
        _seed_history_records(app_server, job_id, seeded)

        history_resp = app_server.api_request(
            "GET",
            f"/api/cron/jobs/{job_id}/history",
            timeout=_CRON_HTTP_TIMEOUT,
        )
        assert history_resp.status_code == 200, app_server.logs_tail()
        records = history_resp.json()
        assert isinstance(records, list)
        assert len(records) == 3, records

        # API returns records in the same order they appear on disk.
        for returned, want in zip(records, seeded):
            assert returned.get("status") == want["status"]
            assert returned.get("trigger") == want["trigger"]
            assert returned.get("error") == want["error"]
            # run_at may be normalised to ISO string with or without tz;
            # just confirm it round-trips a stable value.
            assert isinstance(returned.get("run_at"), str)
    finally:
        _delete_cron_job_quietly(app_server, job_id)


@pytest.mark.integration
@pytest.mark.p2
def test_cron_header_dispatch_targets_filter_by_keyword(app_server) -> None:
    """Test purpose:
    - Verify the ``keyword`` filter on dispatch-targets actually
      partitions items: a chat created with user_id=alice should
      appear when querying ``?keyword=alice`` and disappear under a
      synthetic non-matching keyword. Console's "选择派发目标"
      autocomplete relies on this.

    Test flow:
    1. POST /api/chats with channel=console, user_id=alice — creates
       a chat that will surface as a dispatch target.
    2. GET /api/cron/dispatch-targets?keyword=alice — assert items
       includes an entry whose user_id == "alice".
    3. GET ?keyword=integ-no-such-user — assert items is empty (or
       at least does not contain "alice").
    4. finally — delete the chat.

    API endpoints:
    - POST /api/chats
    - GET /api/cron/dispatch-targets
    - DELETE /api/chats/{chat_id}
    """
    user_id = "alice-integ-cron-filter"
    session_id = f"console:{user_id}-session"
    create_resp = app_server.api_request(
        "POST",
        "/api/chats",
        json={
            "name": "integ cron filter chat",
            "session_id": session_id,
            "user_id": user_id,
            "channel": "console",
        },
        timeout=_CRON_HTTP_TIMEOUT,
    )
    assert create_resp.status_code == 200, app_server.logs_tail()
    chat_id = create_resp.json().get("id")
    assert isinstance(chat_id, str) and chat_id

    try:
        # tiny sleep so the chat row is fully persisted before the
        # dispatch-target list re-derives from chats
        time.sleep(0.1)

        match_resp = app_server.api_request(
            "GET",
            "/api/cron/dispatch-targets",
            params={"keyword": user_id},
            timeout=_CRON_HTTP_TIMEOUT,
        )
        assert match_resp.status_code == 200, app_server.logs_tail()
        match_items = match_resp.json().get("items", [])
        assert any(
            item.get("user_id") == user_id for item in match_items
        ), f"alice not in matched items: {match_items}"

        miss_resp = app_server.api_request(
            "GET",
            "/api/cron/dispatch-targets",
            params={"keyword": "integ-no-such-user-xyz"},
            timeout=_CRON_HTTP_TIMEOUT,
        )
        assert miss_resp.status_code == 200, app_server.logs_tail()
        miss_items = miss_resp.json().get("items", [])
        assert not any(
            item.get("user_id") == user_id for item in miss_items
        ), f"alice leaked into non-matching filter: {miss_items}"
    finally:
        app_server.api_request(
            "DELETE",
            f"/api/chats/{chat_id}",
            timeout=_CRON_HTTP_TIMEOUT,
        )
