# -*- coding: utf-8 -*-
"""调度路由单元测试

覆盖 5 个端点：list / create / update / delete / toggle
通过 mock 绕过数据库，专注路由逻辑验证。
"""

import os

os.environ.setdefault("JWT_SECRET_KEY", "test-secret-key-for-unit-tests")

import json
from datetime import datetime, timezone
from unittest.mock import MagicMock, patch

import pytest
from fastapi.testclient import TestClient

from app.auth.dependencies import CurrentUser, get_current_user
from app.main import app

_TEST_USER = CurrentUser(user_id=1, site_id=100)


def _override_auth():
    return _TEST_USER


app.dependency_overrides[get_current_user] = _override_auth
client = TestClient(app)

_NOW = datetime(2024, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
_NEXT = datetime(2024, 6, 2, 4, 0, 0, tzinfo=timezone.utc)

_SCHEDULE_CONFIG = {
    "schedule_type": "daily",
    "daily_time": "04:00",
}

_VALID_CREATE = {
    "name": "每日全量同步",
    "task_codes": ["ODS_MEMBER", "ODS_ORDER"],
    "task_config": {"tasks": ["ODS_MEMBER", "ODS_ORDER"], "flow": "api_ods"},
    "schedule_config": _SCHEDULE_CONFIG,
}

# 模拟数据库返回的完整行（13 列，与 _SELECT_COLS 对应）
_DB_ROW = (
    "sched-1", 100, "每日全量同步", ["ODS_MEMBER", "ODS_ORDER"],
    json.dumps({"tasks": ["ODS_MEMBER"], "flow": "api_ods"}),
    json.dumps(_SCHEDULE_CONFIG),
    True, None, _NEXT, 0, None, _NOW, _NOW,
)


def _mock_conn_with_fetchall(rows):
    """构造返回 fetchall 的 mock 连接。"""
    mock_cursor = MagicMock()
    mock_cursor.fetchall.return_value = rows
    mock_conn = MagicMock()
    mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
    mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
    return mock_conn, mock_cursor


def _mock_conn_with_fetchone(row):
    """构造返回 fetchone 的 mock 连接。"""
    mock_cursor = MagicMock()
    mock_cursor.fetchone.return_value = row
    mock_conn = MagicMock()
    mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
    mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
    return mock_conn, mock_cursor


# ---------------------------------------------------------------------------
# GET /api/schedules
# ---------------------------------------------------------------------------

class TestListSchedules:
    @patch("app.routers.schedules.get_connection")
    def test_list_returns_schedules(self, mock_get_conn):
        mock_conn, _ = _mock_conn_with_fetchall([_DB_ROW])
        mock_get_conn.return_value = mock_conn

        resp = client.get("/api/schedules")
        assert resp.status_code == 200
        data = resp.json()
        assert len(data) == 1
        assert data[0]["id"] == "sched-1"
        assert data[0]["name"] == "每日全量同步"
        assert data[0]["site_id"] == 100
        assert data[0]["enabled"] is True

    @patch("app.routers.schedules.get_connection")
    def test_list_empty(self, mock_get_conn):
        mock_conn, _ = _mock_conn_with_fetchall([])
        mock_get_conn.return_value = mock_conn

        resp = client.get("/api/schedules")
        assert resp.status_code == 200
        assert resp.json() == []

    @patch("app.routers.schedules.get_connection")
    def test_list_filters_by_site_id(self, mock_get_conn):
        mock_conn, mock_cursor = _mock_conn_with_fetchall([])
        mock_get_conn.return_value = mock_conn

        client.get("/api/schedules")
        call_args = mock_cursor.execute.call_args
        assert call_args[0][1] == (100,)


# ---------------------------------------------------------------------------
# POST /api/schedules
# ---------------------------------------------------------------------------

class TestCreateSchedule:
    @patch("app.routers.schedules.calculate_next_run", return_value=_NEXT)
    @patch("app.routers.schedules.get_connection")
    def test_create_returns_201(self, mock_get_conn, mock_calc):
        mock_conn, mock_cursor = _mock_conn_with_fetchone(_DB_ROW)
        mock_get_conn.return_value = mock_conn

        resp = client.post("/api/schedules", json=_VALID_CREATE)
        assert resp.status_code == 201
        data = resp.json()
        assert data["id"] == "sched-1"
        assert data["name"] == "每日全量同步"

    @patch("app.routers.schedules.calculate_next_run", return_value=_NEXT)
    @patch("app.routers.schedules.get_connection")
    def test_create_injects_site_id(self, mock_get_conn, mock_calc):
        mock_conn, mock_cursor = _mock_conn_with_fetchone(_DB_ROW)
        mock_get_conn.return_value = mock_conn

        client.post("/api/schedules", json=_VALID_CREATE)
        # INSERT 的第一个参数应为 site_id=100
        insert_params = mock_cursor.execute.call_args[0][1]
        assert insert_params[0] == 100

    def test_create_missing_name_returns_422(self):
        body = {**_VALID_CREATE}
        del body["name"]
        resp = client.post("/api/schedules", json=body)
        assert resp.status_code == 422

    def test_create_invalid_schedule_type_returns_422(self):
        body = {**_VALID_CREATE, "schedule_config": {"schedule_type": "invalid"}}
        resp = client.post("/api/schedules", json=body)
        assert resp.status_code == 422


# ---------------------------------------------------------------------------
# PUT /api/schedules/{id}
# ---------------------------------------------------------------------------

class TestUpdateSchedule:
    @patch("app.routers.schedules.get_connection")
    def test_update_name(self, mock_get_conn):
        updated_row = list(_DB_ROW)
        updated_row[2] = "新名称"
        mock_conn, _ = _mock_conn_with_fetchone(tuple(updated_row))
        mock_get_conn.return_value = mock_conn

        resp = client.put("/api/schedules/sched-1", json={"name": "新名称"})
        assert resp.status_code == 200
        assert resp.json()["name"] == "新名称"

    @patch("app.routers.schedules.calculate_next_run", return_value=_NEXT)
    @patch("app.routers.schedules.get_connection")
    def test_update_schedule_config_recalculates_next_run(self, mock_get_conn, mock_calc):
        mock_conn, _ = _mock_conn_with_fetchone(_DB_ROW)
        mock_get_conn.return_value = mock_conn

        resp = client.put("/api/schedules/sched-1", json={
            "schedule_config": {"schedule_type": "interval", "interval_value": 2, "interval_unit": "hours"},
        })
        assert resp.status_code == 200
        mock_calc.assert_called_once()

    @patch("app.routers.schedules.get_connection")
    def test_update_not_found(self, mock_get_conn):
        mock_conn, _ = _mock_conn_with_fetchone(None)
        mock_get_conn.return_value = mock_conn

        resp = client.put("/api/schedules/nonexistent", json={"name": "x"})
        assert resp.status_code == 404

    def test_update_empty_body_returns_422(self):
        resp = client.put("/api/schedules/sched-1", json={})
        assert resp.status_code == 422


# ---------------------------------------------------------------------------
# DELETE /api/schedules/{id}
# ---------------------------------------------------------------------------

class TestDeleteSchedule:
    @patch("app.routers.schedules.get_connection")
    def test_delete_success(self, mock_get_conn):
        mock_cursor = MagicMock()
        mock_cursor.rowcount = 1
        mock_conn = MagicMock()
        mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
        mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
        mock_get_conn.return_value = mock_conn

        resp = client.delete("/api/schedules/sched-1")
        assert resp.status_code == 200
        assert "已删除" in resp.json()["message"]

    @patch("app.routers.schedules.get_connection")
    def test_delete_not_found(self, mock_get_conn):
        mock_cursor = MagicMock()
        mock_cursor.rowcount = 0
        mock_conn = MagicMock()
        mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
        mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
        mock_get_conn.return_value = mock_conn

        resp = client.delete("/api/schedules/nonexistent")
        assert resp.status_code == 404


# ---------------------------------------------------------------------------
# PATCH /api/schedules/{id}/toggle
# ---------------------------------------------------------------------------

class TestToggleSchedule:
    @patch("app.routers.schedules.calculate_next_run", return_value=_NEXT)
    @patch("app.routers.schedules.get_connection")
    def test_toggle_disable(self, mock_get_conn, mock_calc):
        """启用 → 禁用：next_run_at 应置 NULL"""
        # 第一次 fetchone 返回当前状态（enabled=True）
        # 第二次 fetchone 返回更新后的行
        disabled_row = list(_DB_ROW)
        disabled_row[6] = False   # enabled
        disabled_row[8] = None    # next_run_at

        mock_cursor = MagicMock()
        mock_cursor.fetchone.side_effect = [
            (True, json.dumps(_SCHEDULE_CONFIG)),  # SELECT 当前状态
            tuple(disabled_row),                    # UPDATE RETURNING
        ]
        mock_conn = MagicMock()
        mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
        mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
        mock_get_conn.return_value = mock_conn

        resp = client.patch("/api/schedules/sched-1/toggle")
        assert resp.status_code == 200
        data = resp.json()
        assert data["enabled"] is False
        assert data["next_run_at"] is None

    @patch("app.routers.schedules.calculate_next_run", return_value=_NEXT)
    @patch("app.routers.schedules.get_connection")
    def test_toggle_enable(self, mock_get_conn, mock_calc):
        """禁用 → 启用：next_run_at 应被重新计算"""
        enabled_row = list(_DB_ROW)
        enabled_row[6] = True
        enabled_row[8] = _NEXT

        mock_cursor = MagicMock()
        mock_cursor.fetchone.side_effect = [
            (False, json.dumps(_SCHEDULE_CONFIG)),  # SELECT 当前状态（disabled）
            tuple(enabled_row),                      # UPDATE RETURNING
        ]
        mock_conn = MagicMock()
        mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
        mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
        mock_get_conn.return_value = mock_conn

        resp = client.patch("/api/schedules/sched-1/toggle")
        assert resp.status_code == 200
        data = resp.json()
        assert data["enabled"] is True
        assert data["next_run_at"] is not None
        mock_calc.assert_called_once()

    @patch("app.routers.schedules.get_connection")
    def test_toggle_not_found(self, mock_get_conn):
        mock_cursor = MagicMock()
        mock_cursor.fetchone.return_value = None
        mock_conn = MagicMock()
        mock_conn.cursor.return_value.__enter__ = MagicMock(return_value=mock_cursor)
        mock_conn.cursor.return_value.__exit__ = MagicMock(return_value=False)
        mock_get_conn.return_value = mock_conn

        resp = client.patch("/api/schedules/nonexistent/toggle")
        assert resp.status_code == 404


# ---------------------------------------------------------------------------
# 认证测试
# ---------------------------------------------------------------------------

class TestSchedulesAuth:
    def test_requires_auth(self):
        """移除认证覆盖后，所有端点应返回 401/403"""
        app.dependency_overrides.pop(get_current_user, None)
        try:
            assert client.get("/api/schedules").status_code in (401, 403)
            assert client.post("/api/schedules", json=_VALID_CREATE).status_code in (401, 403)
            assert client.put("/api/schedules/x", json={"name": "x"}).status_code in (401, 403)
            assert client.delete("/api/schedules/x").status_code in (401, 403)
            assert client.patch("/api/schedules/x/toggle").status_code in (401, 403)
        finally:
            app.dependency_overrides[get_current_user] = _override_auth
