"""INDL-34 Service Scheduling Phase 2 — pure-unit tests (no DB / no S3).

These cover the schema validators, status FSM invariant, filename sanitisation,
MIME->extension mapping, S3 key format, activity-log allow-list, and the
log_event metadata sanitiser. They run even when Postgres/Redis are unavailable.

Spec: docs/34_INDL-34_Service_Scheduling_Phase2_Core.md
Covers: SAC-03/S-03 (status on create), S-12/SAC-06 (extra=forbid),
S-04/SAC-05 (filename + S3 key), S-05 (MIME map), S-09/API8 (activity allow-list).
"""
import datetime as _dt
import re
import uuid

import pytest
from pydantic import ValidationError

from src.apps.scheduling.schemas.scheduling import (
    ActivityLogEntrySchema,
    CREATE_STATUSES,
    DocumentSchema,
    RunSheetItemSchema,
    SchedulingView,
    ServiceCreate,
    ServiceDetail,
    ServiceListItem,
    ServiceUpdate,
    StatusUpdateRequest,
)
from src.apps.scheduling.services.scheduling_service import (
    ALLOWED_MIME_TYPES,
    MAX_FILE_SIZE_BYTES,
    MIME_TO_EXT,
    SchedulingService,
    _BAD_FILENAME_RE,
    _sanitize_log_value,
)
from src.core.email import sanitize_header_value
from src.worker.arq_app import _is_valid_recipient, _mask_recipient


def _create_payload(**overrides) -> dict:
    data = {
        "service_type": "Burial",
        "date": "2026-08-01",
        "start_time": "10:00:00",
    }
    data.update(overrides)
    return data


# ── ServiceCreate.status validator (SAC-03 / S-03 / A04) ─────────────────────

def test_create_status_defaults_to_draft():
    m = ServiceCreate(**_create_payload())
    assert m.status == "draft"


@pytest.mark.parametrize("status", ["draft", "awaiting_family_confirm"])
def test_create_accepts_pre_confirm_statuses(status):
    m = ServiceCreate(**_create_payload(status=status))
    assert m.status == status


def test_create_rejects_confirmed_status():
    with pytest.raises(ValidationError):
        ServiceCreate(**_create_payload(status="confirmed"))


def test_create_rejects_garbage_status():
    with pytest.raises(ValidationError):
        ServiceCreate(**_create_payload(status="tentative"))


def test_create_statuses_constant_is_pre_confirm_only():
    assert CREATE_STATUSES == {"draft", "awaiting_family_confirm"}


# ── extra='forbid' / mass-assignment (S-12 / SAC-06 / API3) ──────────────────

def test_create_rejects_reminder_job_id():
    with pytest.raises(ValidationError):
        ServiceCreate(**_create_payload(reminder_job_id="fake-job-123"))


def test_create_rejects_tenant_id():
    with pytest.raises(ValidationError):
        ServiceCreate(**_create_payload(tenant_id=str(uuid.uuid4())))


def test_create_rejects_arbitrary_unknown_field():
    with pytest.raises(ValidationError):
        ServiceCreate(**_create_payload(created_by=str(uuid.uuid4())))


def test_update_has_no_status_field_and_rejects_it():
    # A04 / S-03: ServiceUpdate must not accept `status` at all.
    assert "status" not in ServiceUpdate.model_fields
    with pytest.raises(ValidationError):
        ServiceUpdate(**_create_payload(status="confirmed"))


def test_update_rejects_reminder_job_id_and_tenant_id():
    with pytest.raises(ValidationError):
        ServiceUpdate(**_create_payload(reminder_job_id="x"))
    with pytest.raises(ValidationError):
        ServiceUpdate(**_create_payload(tenant_id=str(uuid.uuid4())))


# ── EmailStr on family_contact_email ─────────────────────────────────────────

def test_create_rejects_invalid_email():
    with pytest.raises(ValidationError):
        ServiceCreate(**_create_payload(family_contact_email="not-an-email"))


def test_create_accepts_valid_email():
    m = ServiceCreate(**_create_payload(family_contact_email="jane@example.com"))
    assert m.family_contact_email == "jane@example.com"


# ── SchedulingView enum ──────────────────────────────────────────────────────

def test_scheduling_view_values():
    assert SchedulingView.LIST.value == "list"
    assert SchedulingView.KANBAN.value == "kanban"


def test_scheduling_view_rejects_freeform():
    with pytest.raises(ValueError):
        SchedulingView("badvalue")


# ── StatusUpdateRequest (PATCH body must be 'confirmed') ─────────────────────

def test_status_update_accepts_confirmed():
    assert StatusUpdateRequest(status="confirmed").status == "confirmed"


@pytest.mark.parametrize("bad", ["draft", "awaiting_family_confirm", "deleted", ""])
def test_status_update_rejects_non_confirmed(bad):
    with pytest.raises(ValidationError):
        StatusUpdateRequest(status=bad)


def test_status_update_rejects_extra_fields():
    with pytest.raises(ValidationError):
        StatusUpdateRequest(status="confirmed", service_id=str(uuid.uuid4()))


# ── Filename sanitisation regex (S-04 / SAC-05) ──────────────────────────────

@pytest.mark.parametrize(
    "bad_name",
    [
        "../../etc/passwd",
        "..\\..\\secrets.env",
        "%2e%2e%2fpasswd",
        "%2E%2E/passwd",
        "sub/dir/file.pdf",
        "back\\slash.pdf",
        "null\x00byte.pdf",
        "file%2fslash.pdf",
        "file%5cslash.pdf",
    ],
)
def test_bad_filename_regex_rejects_traversal(bad_name):
    assert _BAD_FILENAME_RE.search(bad_name) is not None


@pytest.mark.parametrize(
    "ok_name",
    ["death_certificate.pdf", "scan 01.jpg", "permit-2026.png", "Report_Final.docx"],
)
def test_bad_filename_regex_accepts_normal_names(ok_name):
    assert _BAD_FILENAME_RE.search(ok_name) is None


# ── MIME -> extension mapping (S-05) ─────────────────────────────────────────

def test_mime_to_ext_mapping():
    assert MIME_TO_EXT["application/pdf"] == "pdf"
    assert MIME_TO_EXT["image/jpeg"] == "jpg"
    assert MIME_TO_EXT["image/png"] == "png"
    assert MIME_TO_EXT["image/tiff"] == "tiff"
    assert (
        MIME_TO_EXT[
            "application/vnd.openxmlformats-officedocument."
            "wordprocessingml.document"
        ]
        == "docx"
    )


def test_allowed_mime_types_matches_map_keys():
    assert ALLOWED_MIME_TYPES == set(MIME_TO_EXT.keys())


def test_extensions_are_safe_lowercase_alnum():
    for ext in MIME_TO_EXT.values():
        assert re.fullmatch(r"[a-z0-9]+", ext)


def test_max_file_size_is_25mb():
    assert MAX_FILE_SIZE_BYTES == 25 * 1024 * 1024


def test_s3_key_format_regex():
    # Server-built key: scheduling/<uuid>/<uuid>/<uuid>.<ext>  (S-04 / V12.3.1)
    tenant = uuid.uuid4()
    service = uuid.uuid4()
    key = f"scheduling/{tenant}/{service}/{uuid.uuid4()}.pdf"
    pattern = (
        r"^scheduling/[0-9a-f-]{36}/[0-9a-f-]{36}/[0-9a-f-]{36}\.[a-z0-9]+$"
    )
    assert re.match(pattern, key)


# ── Output schema allow-lists (S-09 / API8) ──────────────────────────────────

def test_activity_log_entry_has_no_metadata_field():
    assert "metadata" not in ActivityLogEntrySchema.model_fields
    assert "log_metadata" not in ActivityLogEntrySchema.model_fields
    expected = {
        "id",
        "event_type",
        "from_status",
        "to_status",
        "description",
        "actor_name",
        "created_at",
    }
    assert set(ActivityLogEntrySchema.model_fields.keys()) == expected


def test_service_detail_has_no_reminder_job_id_or_tenant_id():
    assert "reminder_job_id" not in ServiceDetail.model_fields
    assert "tenant_id" not in ServiceDetail.model_fields


def test_service_list_item_has_no_reminder_job_id_or_tenant_id():
    assert "reminder_job_id" not in ServiceListItem.model_fields
    assert "tenant_id" not in ServiceListItem.model_fields


def test_document_schema_exposes_download_url_not_s3_key():
    assert "download_url" in DocumentSchema.model_fields
    assert "s3_key" not in DocumentSchema.model_fields


# ── log_event metadata sanitiser ─────────────────────────────────────────────

def test_sanitize_log_value_passes_scalars():
    assert _sanitize_log_value(None) is None
    assert _sanitize_log_value(True) is True
    assert _sanitize_log_value(42) == 42
    assert _sanitize_log_value(3.5) == 3.5


def test_sanitize_log_value_caps_long_strings():
    out = _sanitize_log_value("a" * 500)
    assert len(out) == 120
    assert out.endswith("...")


def test_sanitize_log_value_short_string_unchanged():
    assert _sanitize_log_value("decedent_name") == "decedent_name"


# ── RunSheetItemSchema forbids extras ────────────────────────────────────────

def test_run_sheet_item_rejects_extra_fields():
    with pytest.raises(ValidationError):
        RunSheetItemSchema(sort_order=1, time_label="10:00", details="x", foo="bar")


# ── INDL-38: family notification helpers ─────────────────────────────────────
# Spec: docs/38_INDL-38_Service_Scheduling_Phase2_Notifications.md

class _FakeEvent:
    """Minimal stand-in for a ServiceEvent, just enough for _service_datetime."""

    def __init__(self, scheduled_date=None, scheduled_time=None):
        self.scheduled_date = scheduled_date
        self.scheduled_time = scheduled_time


def test_service_datetime_none_without_date():
    assert SchedulingService._service_datetime(_FakeEvent()) is None


def test_service_datetime_combines_date_and_time_as_utc():
    event = _FakeEvent(scheduled_date=_dt.date(2026, 8, 1), scheduled_time=_dt.time(10, 30))
    result = SchedulingService._service_datetime(event)
    assert result == _dt.datetime(2026, 8, 1, 10, 30, tzinfo=_dt.timezone.utc)


def test_service_datetime_defaults_missing_time_to_midnight():
    event = _FakeEvent(scheduled_date=_dt.date(2026, 8, 1), scheduled_time=None)
    result = SchedulingService._service_datetime(event)
    assert result == _dt.datetime(2026, 8, 1, 0, 0, tzinfo=_dt.timezone.utc)


# ── SEC-AC-02: subject-line header-injection sanitisation ───────────────────

def test_sanitize_header_value_strips_crlf():
    assert sanitize_header_value("John Smith\r\nBCC: evil@test.com") == "John SmithBCC: evil@test.com"


def test_sanitize_header_value_strips_bare_lf_and_null():
    assert sanitize_header_value("a\nb\x00c") == "abc"


def test_sanitize_header_value_falls_back_to_default_when_empty():
    assert sanitize_header_value(None, default="service") == "service"
    assert sanitize_header_value("   ", default="service") == "service"


# ── SEC-AC-05: recipient validation before any send ──────────────────────────

def test_is_valid_recipient_accepts_valid_email():
    assert _is_valid_recipient("jane@example.com") is True


@pytest.mark.parametrize("bad", [None, "", "not-an-email", "no-domain@", "@no-local.com"])
def test_is_valid_recipient_rejects_invalid(bad):
    assert _is_valid_recipient(bad) is False


# ── SEC-AC-06: recipient masking for activity-log metadata ───────────────────

def test_mask_recipient_keeps_domain_masks_local_part():
    assert _mask_recipient("jane@example.com") == "j***@example.com"


def test_mask_recipient_single_char_local_part():
    assert _mask_recipient("j@example.com") == "j***@example.com"
