# Feature: trading-feedback-engine, Property 2: Report serialization round-trip
"""Property-based tests for report serialization round-trip.

Feature: trading-feedback-engine

Tests the report serialization round-trip property from the design
specification: for any valid ReportData object (with valid P&L,
recommendation accuracy, position performance, risk metrics, and model
quality sections), serializing to JSON and then deserializing back SHALL
produce a ReportData object equivalent to the original. All datetime fields
in the serialized JSON SHALL be in ISO 8601 format.
"""
from __future__ import annotations

import json
import re
from datetime import date, datetime, timezone

from hypothesis import given, settings
from hypothesis import strategies as st

from services.reporting.models import (
    ModelQualitySection,
    ModelQualityWindow,
    PLSection,
    PositionDetail,
    PositionPerformanceSection,
    RecommendationAccuracySection,
    ReportData,
    ReportType,
    RiskMetricsSection,
    ValidationStatus,
    ValidationWarning,
)

# ---------------------------------------------------------------------------
# Property 2: Report Serialization Round-Trip
# Validates: Requirements 8.1, 8.2, 8.3, 8.4
# ---------------------------------------------------------------------------

# ISO 8601 datetime pattern (covers both datetime and date formats)
_ISO8601_DATETIME_RE = re.compile(
    r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}"  # YYYY-MM-DDTHH:MM:SS
    r"(?:\.\d+)?"  # optional fractional seconds
    r"(?:Z|[+-]\d{2}:\d{2})?$"  # optional timezone
)
_ISO8601_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")

# ---------------------------------------------------------------------------
# Hypothesis strategies for each model
# ---------------------------------------------------------------------------

_finite_float = st.floats(allow_nan=False, allow_infinity=False)
_non_negative_finite_float = st.floats(
    min_value=0.0, allow_nan=False, allow_infinity=False,
)
_rate_float = st.floats(
    min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False,
)
_optional_finite_float = st.one_of(st.none(), _finite_float)

_validation_warning_strategy = st.builds(
    ValidationWarning,
    field_name=st.text(min_size=1, max_size=50),
    computed_value=_finite_float,
    snapshot_value=_finite_float,
    pct_difference=_non_negative_finite_float,
)

_pnl_section_strategy = st.builds(
    PLSection,
    realized_pnl=_finite_float,
    unrealized_pnl=_finite_float,
    daily_return=_finite_float,
    cumulative_return=_finite_float,
    win_count=st.integers(min_value=0, max_value=10000),
    loss_count=st.integers(min_value=0, max_value=10000),
    win_rate=_rate_float,
    profit_factor=_non_negative_finite_float,
    sharpe_ratio=_finite_float,
    summary=st.text(max_size=200),
    validation_warnings=st.lists(
        _validation_warning_strategy, min_size=0, max_size=3,
    ),
)

_recommendation_accuracy_strategy = st.builds(
    RecommendationAccuracySection,
    total_evaluated=st.integers(min_value=0, max_value=10000),
    act_count=st.integers(min_value=0, max_value=10000),
    skip_count=st.integers(min_value=0, max_value=10000),
    acted_win_rate=_rate_float,
    avg_confidence_acted=_rate_float,
    avg_confidence_skipped=_rate_float,
    summary=st.text(max_size=200),
    validation_warnings=st.lists(
        _validation_warning_strategy, min_size=0, max_size=3,
    ),
)

_position_detail_strategy = st.builds(
    PositionDetail,
    ticker=st.text(min_size=1, max_size=10),
    entry_price=_finite_float,
    current_or_exit_price=_finite_float,
    pnl=_finite_float,
    pnl_pct=_finite_float,
    hold_duration_hours=_non_negative_finite_float,
    status=st.sampled_from(["open", "closed"]),
)

_position_performance_strategy = st.builds(
    PositionPerformanceSection,
    positions=st.lists(_position_detail_strategy, min_size=0, max_size=5),
    summary=st.text(max_size=200),
)

_risk_metrics_strategy = st.builds(
    RiskMetricsSection,
    current_risk_tier=st.sampled_from(["low", "moderate", "high", "critical"]),
    portfolio_heat=_non_negative_finite_float,
    max_drawdown=_non_negative_finite_float,
    current_drawdown_pct=_non_negative_finite_float,
    reserve_pool_balance=_non_negative_finite_float,
    circuit_breaker_event_count=st.integers(min_value=0, max_value=100),
    summary=st.text(max_size=200),
)

_model_quality_window_strategy = st.builds(
    ModelQualityWindow,
    lookback=st.sampled_from(["7d", "30d", "90d"]),
    win_rate=_optional_finite_float,
    directional_accuracy=_optional_finite_float,
    information_coefficient=_optional_finite_float,
    calibration_error=_optional_finite_float,
    brier_score=_optional_finite_float,
)

_model_quality_strategy = st.builds(
    ModelQualitySection,
    windows=st.lists(_model_quality_window_strategy, min_size=0, max_size=3),
    summary=st.text(max_size=200),
    validation_warnings=st.lists(
        _validation_warning_strategy, min_size=0, max_size=3,
    ),
)

# Use timezone-aware datetimes for generated_at
_aware_datetime_strategy = st.datetimes(
    min_value=datetime(2020, 1, 1),
    max_value=datetime(2030, 12, 31),
    timezones=st.just(timezone.utc),
)

_date_strategy = st.dates(
    min_value=date(2020, 1, 1),
    max_value=date(2030, 12, 31),
)

_report_data_strategy = st.builds(
    ReportData,
    pnl=_pnl_section_strategy,
    recommendation_accuracy=_recommendation_accuracy_strategy,
    position_performance=_position_performance_strategy,
    risk_metrics=_risk_metrics_strategy,
    model_quality=_model_quality_strategy,
    executive_summary=st.text(max_size=300),
    validation_status=st.sampled_from(list(ValidationStatus)),
    generated_at=_aware_datetime_strategy,
    period_start=_date_strategy,
    period_end=_date_strategy,
    report_type=st.sampled_from(list(ReportType)),
)


# ---------------------------------------------------------------------------
# Helper: recursively find all datetime-like string values in parsed JSON
# ---------------------------------------------------------------------------

_DATETIME_FIELD_NAMES = {"generated_at"}
_DATE_FIELD_NAMES = {"period_start", "period_end"}


def _collect_datetime_strings(
    obj: object,
    key: str | None = None,
) -> list[tuple[str, str]]:
    """Walk parsed JSON and collect (field_name, value) for datetime fields."""
    results: list[tuple[str, str]] = []
    if isinstance(obj, dict):
        for k, v in obj.items():
            results.extend(_collect_datetime_strings(v, k))
    elif isinstance(obj, list):
        for item in obj:
            results.extend(_collect_datetime_strings(item, key))
    elif isinstance(obj, str) and key is not None:
        if key in _DATETIME_FIELD_NAMES or key in _DATE_FIELD_NAMES:
            results.append((key, obj))
    return results


# ---------------------------------------------------------------------------
# Property tests
# ---------------------------------------------------------------------------


@given(report=_report_data_strategy)
@settings(max_examples=100)
def test_report_serialization_round_trip(report: ReportData) -> None:
    """**Validates: Requirements 8.1, 8.2, 8.3, 8.4**

    For any valid ReportData object, serializing to JSON and then
    deserializing back SHALL produce a ReportData object equivalent
    to the original.
    """
    json_str = report.model_dump_json()
    restored = ReportData.model_validate_json(json_str)
    assert restored == report, (
        f"Round-trip failed: deserialized report differs from original.\n"
        f"  report_type: {report.report_type}\n"
        f"  period: {report.period_start} → {report.period_end}\n"
        f"  generated_at: {report.generated_at}"
    )


@given(report=_report_data_strategy)
@settings(max_examples=100)
def test_report_datetime_fields_iso8601(report: ReportData) -> None:
    """**Validates: Requirements 8.4**

    All datetime fields in the serialized JSON SHALL be in ISO 8601 format.
    """
    json_str = report.model_dump_json()
    parsed = json.loads(json_str)
    dt_fields = _collect_datetime_strings(parsed)

    for field_name, value in dt_fields:
        if field_name in _DATETIME_FIELD_NAMES:
            assert _ISO8601_DATETIME_RE.match(value), (
                f"Datetime field '{field_name}' is not ISO 8601: {value!r}"
            )
        elif field_name in _DATE_FIELD_NAMES:
            assert _ISO8601_DATE_RE.match(value), (
                f"Date field '{field_name}' is not ISO 8601: {value!r}"
            )
