"""Tests for FastAPI app /api/duties."""

import time
from unittest.mock import ANY, patch

import pytest
from fastapi.testclient import TestClient

import duty_teller.config as config
from duty_teller.api.app import app
from tests.helpers import make_init_data


@pytest.fixture
def client():
    return TestClient(app)


def test_health(client):
    """Health endpoint returns 200 and status ok for Docker HEALTHCHECK."""
    r = client.get("/health")
    assert r.status_code == 200
    assert r.json() == {"status": "ok"}


def test_unhandled_exception_returns_500_json(client):
    """Global exception handler returns 500 JSON without leaking exception details."""
    from unittest.mock import MagicMock

    from duty_teller.api.app import global_exception_handler

    # Call the registered handler directly: it returns JSON and does not expose str(exc).
    request = MagicMock()
    exc = RuntimeError("internal failure")
    response = global_exception_handler(request, exc)
    assert response.status_code == 500
    assert response.body.decode() == '{"detail":"Internal server error"}'
    assert "internal failure" not in response.body.decode()


def test_health_has_vary_accept_language(client):
    """NoCacheStaticMiddleware adds Vary: Accept-Language to all responses."""
    r = client.get("/health")
    assert r.status_code == 200
    assert "accept-language" in r.headers.get("vary", "").lower()


def test_app_static_has_no_store_and_vary(client):
    """Static files under /app get Cache-Control: no-store and Vary: Accept-Language."""
    r = client.get("/app/")
    if r.status_code != 200:
        r = client.get("/app")
    assert r.status_code == 200, (
        "webapp static mount should serve index at /app or /app/"
    )
    assert r.headers.get("cache-control") == "no-store"
    assert "accept-language" in r.headers.get("vary", "").lower()


def test_app_js_has_no_store(client):
    """JS and all static under /app get Cache-Control: no-store."""
    webapp_out = config.PROJECT_ROOT / "webapp-next" / "out"
    if not webapp_out.is_dir():
        pytest.skip("webapp-next/out not built")
    # Next.js static export serves JS under _next/static/chunks/<hash>.js
    js_files = list(webapp_out.glob("_next/static/chunks/*.js"))
    if not js_files:
        pytest.skip("no JS chunks in webapp-next/out")
    rel = js_files[0].relative_to(webapp_out)
    r = client.get(f"/app/{rel.as_posix()}")
    assert r.status_code == 200
    assert r.headers.get("cache-control") == "no-store"


def test_app_config_js_returns_lang_from_default_language(client):
    """GET /app/config.js returns JS setting window.__DT_LANG from config.DEFAULT_LANGUAGE."""
    r = client.get("/app/config.js")
    assert r.status_code == 200
    assert r.headers.get("content-type", "").startswith("application/javascript")
    assert r.headers.get("cache-control") == "no-store"
    body = r.text
    assert "window.__DT_LANG" in body
    assert config.DEFAULT_LANGUAGE in body


@patch("duty_teller.api.app.config.DEFAULT_LANGUAGE", '"; alert(1); "')
@patch("duty_teller.api.app.config.LOG_LEVEL_STR", "DEBUG\x00INJECT")
def test_app_config_js_sanitizes_lang_and_log_level(client):
    """config.js uses whitelist: invalid lang/log_level produce safe defaults, no script injection."""
    r = client.get("/app/config.js")
    assert r.status_code == 200
    body = r.text
    # Must be valid JS and not contain the raw malicious strings.
    assert 'window.__DT_LANG = "en"' in body or 'window.__DT_LANG = "ru"' in body
    assert "alert" not in body
    assert "INJECT" not in body
    assert "window.__DT_LOG_LEVEL" in body


def test_duties_invalid_date_format(client):
    r = client.get("/api/duties", params={"from": "2025-01-01", "to": "invalid"})
    assert r.status_code == 400
    detail = r.json()["detail"]
    assert "from" in detail.lower() and "to" in detail.lower()


def test_duties_from_after_to(client):
    r = client.get("/api/duties", params={"from": "2025-02-01", "to": "2025-01-01"})
    assert r.status_code == 400
    detail = r.json()["detail"].lower()
    assert "from" in detail or "to" in detail or "after" in detail or "позже" in detail


@patch("duty_teller.api.app.config.MINI_APP_SKIP_AUTH", True)
def test_duties_range_too_large_400(client):
    """Date range longer than MAX_DATE_RANGE_DAYS returns 400 with dates.range_too_large message."""
    from datetime import date, timedelta

    from duty_teller.utils.dates import MAX_DATE_RANGE_DAYS

    from_d = date(2020, 1, 1)
    to_d = from_d + timedelta(days=MAX_DATE_RANGE_DAYS + 1)
    r = client.get(
        "/api/duties",
        params={"from": from_d.isoformat(), "to": to_d.isoformat()},
    )
    assert r.status_code == 400
    detail = r.json()["detail"]
    # EN: "Date range is too large. Request a shorter period." / RU: "Диапазон дат слишком большой..."
    assert (
        "range" in detail.lower()
        or "short" in detail.lower()
        or "короткий" in detail
        or "большой" in detail
    )


@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_duties_403_without_init_data(client):
    """Without X-Telegram-Init-Data and without MINI_APP_SKIP_AUTH → 403 (any client)."""
    r = client.get(
        "/api/duties",
        params={"from": "2025-01-01", "to": "2025-01-31"},
    )
    assert r.status_code == 403


@patch("duty_teller.api.app.config.MINI_APP_SKIP_AUTH", True)
@patch("duty_teller.api.app.fetch_duties_response")
def test_duties_200_when_skip_auth(mock_fetch, client):
    mock_fetch.return_value = []
    r = client.get(
        "/api/duties",
        params={"from": "2025-01-01", "to": "2025-01-31"},
    )
    assert r.status_code == 200
    assert r.json() == []
    mock_fetch.assert_called_once_with(ANY, "2025-01-01", "2025-01-31")


@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_duties_403_when_init_data_invalid(mock_validate, client):
    mock_validate.return_value = (None, None, "hash_mismatch", "en")
    r = client.get(
        "/api/duties",
        params={"from": "2025-01-01", "to": "2025-01-31"},
        headers={"X-Telegram-Init-Data": "some=data&hash=abc"},
    )
    assert r.status_code == 403
    detail = r.json()["detail"]
    assert (
        "signature" in detail.lower()
        or "авторизации" in detail
        or "Неверные" in detail
        or "Неверная" in detail
    )


@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_duties_403_when_user_not_in_db(mock_validate, mock_get_user, client):
    """User not in DB -> 403 (access only for users with role or env-admin fallback)."""
    mock_validate.return_value = (123, "someuser", "ok", "en")
    mock_get_user.return_value = None
    with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch:
        r = client.get(
            "/api/duties",
            params={"from": "2025-01-01", "to": "2025-01-31"},
            headers={"X-Telegram-Init-Data": "user=xxx&hash=yyy"},
        )
    assert r.status_code == 403
    assert (
        "Access denied" in r.json()["detail"] or "Доступ запрещён" in r.json()["detail"]
    )
    mock_fetch.assert_not_called()


@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_duties_403_when_no_username_and_no_access(
    mock_validate, mock_get_user, mock_can_access, client
):
    """User in DB but no miniapp access (no role, not env admin) -> 403."""
    from types import SimpleNamespace

    mock_validate.return_value = (456, None, "ok", "en")
    mock_get_user.return_value = SimpleNamespace(
        phone="+79001111111", full_name="User", username=None
    )
    mock_can_access.return_value = False
    with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch:
        r = client.get(
            "/api/duties",
            params={"from": "2025-01-01", "to": "2025-01-31"},
            headers={"X-Telegram-Init-Data": "user=xxx&hash=yyy"},
        )
    assert r.status_code == 403
    mock_fetch.assert_not_called()


@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_duties_200_when_user_has_access(
    mock_validate, mock_get_user, mock_can_access, client
):
    """User in DB with miniapp access (role or env fallback) -> 200."""
    from types import SimpleNamespace

    mock_validate.return_value = (789, None, "ok", "en")
    mock_get_user.return_value = SimpleNamespace(
        phone="+7 900 123-45-67", full_name="Иван", username=None
    )
    mock_can_access.return_value = True
    with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch:
        mock_fetch.return_value = []
        r = client.get(
            "/api/duties",
            params={"from": "2025-01-01", "to": "2025-01-31"},
            headers={"X-Telegram-Init-Data": "user=xxx&hash=yyy"},
        )
    assert r.status_code == 200
    assert r.json() == []
    mock_fetch.assert_called_once_with(ANY, "2025-01-01", "2025-01-31")


@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.config.can_access_miniapp")
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", True)
def test_duties_200_with_valid_init_data_and_skip_auth_not_in_allowlist(
    mock_can_access, mock_validate, client
):
    """With MINI_APP_SKIP_AUTH=True, valid initData grants access without allowlist check."""
    mock_validate.return_value = ("outsider_user", "ok", "en")
    mock_can_access.return_value = False
    with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch:
        mock_fetch.return_value = []
        r = client.get(
            "/api/duties",
            params={"from": "2025-01-01", "to": "2025-01-31"},
            headers={"X-Telegram-Init-Data": "user=xxx&hash=yyy"},
        )
    assert r.status_code == 200
    assert r.json() == []
    mock_fetch.assert_called_once_with(ANY, "2025-01-01", "2025-01-31")
    mock_can_access.assert_not_called()
    mock_validate.assert_not_called()


@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
def test_duties_200_with_allowed_user(
    mock_validate, mock_get_user, mock_can_access, client
):
    from types import SimpleNamespace

    mock_validate.return_value = (1, "alloweduser", "ok", "en")
    mock_get_user.return_value = SimpleNamespace(
        full_name="Иван Иванов", username="alloweduser", phone=None
    )
    mock_can_access.return_value = True
    with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch:
        mock_fetch.return_value = [
            {
                "id": 1,
                "user_id": 10,
                "start_at": "2025-01-15T09:00:00Z",
                "end_at": "2025-01-15T18:00:00Z",
                "full_name": "Иван Иванов",
            }
        ]
        r = client.get(
            "/api/duties",
            params={"from": "2025-01-01", "to": "2025-01-31"},
            headers={"X-Telegram-Init-Data": "user=xxx&hash=yyy"},
        )
    assert r.status_code == 200
    assert len(r.json()) == 1
    assert r.json()[0]["full_name"] == "Иван Иванов"
    mock_fetch.assert_called_once_with(ANY, "2025-01-01", "2025-01-31")


@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
def test_duties_e2e_auth_real_validation(
    mock_get_user, mock_can_access, client, monkeypatch
):
    from types import SimpleNamespace

    test_token = "123:ABC"
    test_username = "e2euser"
    monkeypatch.setattr(config, "BOT_TOKEN", test_token)
    monkeypatch.setattr(config, "ADMIN_USERNAMES", set())
    monkeypatch.setattr(config, "INIT_DATA_MAX_AGE_SECONDS", 0)
    init_data = make_init_data(
        {"id": 1, "username": test_username},
        test_token,
        auth_date=int(time.time()),
    )
    mock_get_user.return_value = SimpleNamespace(
        full_name="E2E User", username=test_username, phone=None
    )
    mock_can_access.return_value = True
    with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch:
        mock_fetch.return_value = []
        r = client.get(
            "/api/duties",
            params={"from": "2025-01-01", "to": "2025-01-31"},
            headers={"X-Telegram-Init-Data": init_data},
        )
    assert r.status_code == 200
    assert r.json() == []
    mock_fetch.assert_called_once_with(ANY, "2025-01-01", "2025-01-31")


@patch("duty_teller.api.app.config.MINI_APP_SKIP_AUTH", True)
def test_duties_200_with_unknown_event_type_mapped_to_duty(client):
    from types import SimpleNamespace

    fake_duty = SimpleNamespace(
        id=1,
        user_id=10,
        start_at="2025-01-15T09:00:00Z",
        end_at="2025-01-15T18:00:00Z",
        event_type="unknown",
    )

    def fake_get_duties(session, from_date, to_date):
        # get_duties returns (Duty, full_name, phone, username) tuples.
        return [(fake_duty, "User A", "+79001234567", "user_a")]

    with patch("duty_teller.api.dependencies.get_duties", side_effect=fake_get_duties):
        r = client.get(
            "/api/duties",
            params={"from": "2025-01-01", "to": "2025-01-31"},
        )
    assert r.status_code == 200
    data = r.json()
    assert len(data) == 1
    assert data[0]["event_type"] == "duty"
    assert data[0]["full_name"] == "User A"
    assert data[0].get("phone") == "+79001234567"
    assert data[0].get("username") == "user_a"


def test_calendar_ical_team_404_invalid_token_format(client):
    """GET /api/calendar/ical/team/{token}.ics with invalid token format returns 404 JSON."""
    r = client.get("/api/calendar/ical/team/short.ics")
    assert r.status_code == 404
    assert r.headers.get("content-type", "").startswith("application/json")
    assert r.json() == {"detail": "Not found"}


@patch("duty_teller.api.app.get_user_by_calendar_token")
def test_calendar_ical_team_404_unknown_token(mock_get_user, client):
    """GET /api/calendar/ical/team/{token}.ics with unknown token returns 404 JSON."""
    mock_get_user.return_value = None
    valid_format_token = "B" * 43
    r = client.get(f"/api/calendar/ical/team/{valid_format_token}.ics")
    assert r.status_code == 404
    assert r.json() == {"detail": "Not found"}
    mock_get_user.assert_called_once()


@patch("duty_teller.api.app.build_team_ics")
@patch("duty_teller.api.app.get_duties")
@patch("duty_teller.api.app.get_user_by_calendar_token")
def test_calendar_ical_team_200_only_duty_and_description(
    mock_get_user, mock_get_duties, mock_build_team_ics, client
):
    """GET /api/calendar/ical/team/{token}.ics returns ICS with duty-only events and DESCRIPTION."""
    from types import SimpleNamespace

    mock_user = SimpleNamespace(id=1, full_name="User A")
    mock_get_user.return_value = mock_user
    duty = SimpleNamespace(
        id=10,
        user_id=1,
        start_at="2026-06-15T09:00:00Z",
        end_at="2026-06-15T18:00:00Z",
        event_type="duty",
    )
    non_duty = SimpleNamespace(
        id=11,
        user_id=2,
        start_at="2026-06-16T09:00:00Z",
        end_at="2026-06-16T18:00:00Z",
        event_type="vacation",
    )
    # get_duties returns (Duty, full_name, phone, username) tuples.
    mock_get_duties.return_value = [
        (duty, "User A", None, None),
        (non_duty, "User B", None, None),
    ]
    mock_build_team_ics.return_value = b"BEGIN:VCALENDAR\r\nPRODID:Team\r\nVEVENT\r\nDESCRIPTION:User A\r\nEND:VCALENDAR"
    token = "y" * 43

    r = client.get(f"/api/calendar/ical/team/{token}.ics")
    assert r.status_code == 200
    assert r.headers.get("content-type", "").startswith("text/calendar")
    assert b"BEGIN:VCALENDAR" in r.content
    mock_get_user.assert_called_once()
    mock_get_duties.assert_called_once()
    # build_team_ics called with only duty (event_type duty), not vacation
    mock_build_team_ics.assert_called_once()
    duties_arg = mock_build_team_ics.call_args[0][0]
    assert len(duties_arg) == 1
    assert duties_arg[0][0].event_type == "duty"
    assert duties_arg[0][1] == "User A"


def test_calendar_ical_404_invalid_token_format(client):
    """GET /api/calendar/ical/{token}.ics with invalid token format returns 404 JSON."""
    r = client.get("/api/calendar/ical/short.ics")
    assert r.status_code == 404
    assert r.json() == {"detail": "Not found"}
    r2 = client.get("/api/calendar/ical/" + "x" * 60 + ".ics")
    assert r2.status_code == 404
    r3 = client.get("/api/calendar/ical/../../../etc/passwd.ics")
    assert r3.status_code == 404


@patch("duty_teller.api.app.get_user_by_calendar_token")
def test_calendar_ical_404_unknown_token(mock_get_user, client):
    """GET /api/calendar/ical/{token}.ics with unknown token returns 404 JSON."""
    mock_get_user.return_value = None
    valid_format_token = "A" * 43
    r = client.get(f"/api/calendar/ical/{valid_format_token}.ics")
    assert r.status_code == 404
    assert r.json() == {"detail": "Not found"}
    mock_get_user.assert_called_once()


@patch("duty_teller.api.app.build_personal_ics")
@patch("duty_teller.api.app.get_duties_for_user")
@patch("duty_teller.api.app.get_user_by_calendar_token")
def test_calendar_ical_200_returns_only_that_users_duties(
    mock_get_user, mock_get_duties, mock_build_ics, client
):
    """GET /api/calendar/ical/{token}.ics returns ICS with only the token owner's duties."""
    from types import SimpleNamespace

    mock_user = SimpleNamespace(id=1, full_name="User A")
    mock_get_user.return_value = mock_user
    duty = SimpleNamespace(
        id=10,
        user_id=1,
        start_at="2026-06-15T09:00:00Z",
        end_at="2026-06-15T18:00:00Z",
        event_type="duty",
    )
    # get_duties_for_user returns (Duty, full_name, phone, username) tuples.
    mock_get_duties.return_value = [(duty, "User A", None, None)]
    mock_build_ics.return_value = (
        b"BEGIN:VCALENDAR\r\nVEVENT\r\n2026-06-15\r\nEND:VCALENDAR"
    )
    # Token must pass format validation (base64url, 40–50 chars)
    token = "x" * 43

    r = client.get(f"/api/calendar/ical/{token}.ics")
    assert r.status_code == 200
    assert r.headers.get("content-type", "").startswith("text/calendar")
    assert b"BEGIN:VCALENDAR" in r.content
    mock_get_user.assert_called_once()
    mock_get_duties.assert_called_once_with(
        ANY, 1, from_date=ANY, to_date=ANY, event_types=["duty"]
    )
    mock_build_ics.assert_called_once()
    # Only User A's duty was passed to build_personal_ics
    duties_arg = mock_build_ics.call_args[0][0]
    assert len(duties_arg) == 1
    assert duties_arg[0][0].user_id == 1
    assert duties_arg[0][1] == "User A"


@patch("duty_teller.api.app.build_personal_ics")
@patch("duty_teller.api.app.get_duties_for_user")
@patch("duty_teller.api.app.get_user_by_calendar_token")
def test_calendar_ical_ignores_unknown_query_params(
    mock_get_user, mock_get_duties, mock_build_ics, client
):
    """Unknown query params (e.g. events=all) are ignored; response is duty-only."""
    from types import SimpleNamespace

    from duty_teller.cache import ics_calendar_cache

    ics_calendar_cache.invalidate(("personal_ics", 1))
    mock_user = SimpleNamespace(id=1, full_name="User A")
    mock_get_user.return_value = mock_user
    duty = SimpleNamespace(
        id=10,
        user_id=1,
        start_at="2026-06-15T09:00:00Z",
        end_at="2026-06-15T18:00:00Z",
        event_type="duty",
    )
    # get_duties_for_user returns (Duty, full_name, phone, username) tuples.
    mock_get_duties.return_value = [(duty, "User A", None, None)]
    mock_build_ics.return_value = b"BEGIN:VCALENDAR\r\nEND:VCALENDAR"
    token = "z" * 43

    r = client.get(f"/api/calendar/ical/{token}.ics", params={"events": "all"})
    assert r.status_code == 200
    mock_get_duties.assert_called_once_with(
        ANY, 1, from_date=ANY, to_date=ANY, event_types=["duty"]
    )


# --- /api/calendar-events ---


@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_calendar_events_403_without_init_data(client):
    """Without X-Telegram-Init-Data and without MINI_APP_SKIP_AUTH → 403."""
    r = client.get(
        "/api/calendar-events",
        params={"from": "2025-01-01", "to": "2025-01-31"},
    )
    assert r.status_code == 403


@patch("duty_teller.api.app.config.EXTERNAL_CALENDAR_ICS_URL", "")
@patch("duty_teller.api.app.config.MINI_APP_SKIP_AUTH", True)
def test_calendar_events_empty_url_returns_empty_list(client):
    """When EXTERNAL_CALENDAR_ICS_URL is empty, GET /api/calendar-events returns [] without fetch."""
    with patch("duty_teller.api.app.get_calendar_events") as mock_get:
        r = client.get(
            "/api/calendar-events",
            params={"from": "2025-01-01", "to": "2025-01-31"},
        )
    assert r.status_code == 200
    assert r.json() == []
    mock_get.assert_not_called()


@patch(
    "duty_teller.api.app.config.EXTERNAL_CALENDAR_ICS_URL",
    "https://example.com/cal.ics",
)
@patch("duty_teller.api.app.config.MINI_APP_SKIP_AUTH", True)
def test_calendar_events_200_returns_list_with_date_summary(client):
    """GET /api/calendar-events with auth and URL set returns list of {date, summary}."""
    with patch("duty_teller.api.app.get_calendar_events") as mock_get:
        mock_get.return_value = [
            {"date": "2025-01-15", "summary": "Team meeting"},
            {"date": "2025-01-20", "summary": "Review"},
        ]
        r = client.get(
            "/api/calendar-events",
            params={"from": "2025-01-01", "to": "2025-01-31"},
        )
    assert r.status_code == 200
    data = r.json()
    assert len(data) == 2
    assert data[0]["date"] == "2025-01-15"
    assert data[0]["summary"] == "Team meeting"
    assert data[1]["date"] == "2025-01-20"
    assert data[1]["summary"] == "Review"
    mock_get.assert_called_once_with(
        "https://example.com/cal.ics",
        from_date="2025-01-01",
        to_date="2025-01-31",
    )
