"""Tests for zero-claim extraction observability (closes #707).

Verifies that:
- Zero-claim extractions are logged at INFO (configurable) with message preview
- Config field extraction_zero_claim_log_level controls verbosity
- The counter increments correctly
- Noise-filtered conversations (extraction_result is None / no signal) do NOT trigger the log
"""
from __future__ import annotations

import asyncio
import logging
from unittest.mock import AsyncMock, MagicMock

import pytest


# ---------------------------------------------------------------------------
# Helper factories
# ---------------------------------------------------------------------------

def _make_config(**kwargs) -> MagicMock:
    defaults = dict(
        max_concurrent_extractions=1,
        max_claims_per_extraction=25,
        max_extraction_calls_per_batch=25,
        extraction_zero_claim_log_level="INFO",
        extraction_zero_claim_log_preview=False,
    )
    defaults.update(kwargs)
    return MagicMock(**defaults)


def _make_extractor(claims=None, noise_filtered=0) -> MagicMock:
    result = MagicMock()
    result.claims = claims if claims is not None else []
    result.noise_filtered = noise_filtered
    extractor = MagicMock()
    extractor.extract = AsyncMock(return_value=result)
    return extractor


async def _run(conversation, config, extractor=None, zero_claim_outcomes=0):
    from cortex.capture.wake_pipeline import run_extraction

    if extractor is None:
        extractor = _make_extractor(claims=[])  # returns 0 claims

    return await run_extraction(
        extractor=extractor,
        storage=AsyncMock(),
        llm=MagicMock(embed=AsyncMock(return_value=[0.1] * 128)),
        entity_resolver=None,
        cornerstone_guardian=None,
        reconciler=None,
        event_emitter=None,
        config=config,
        extraction_semaphore=asyncio.Semaphore(1),
        conversation=conversation,
        sid="test-sid-707",
        db_session_pk=None,
        effective_owner_id="test-owner",
        episode_event_id=None,
        budget_check=lambda n: None,
        budget_consume=lambda n: None,
        errors=[],
        warnings=[],
        zero_claim_outcomes=zero_claim_outcomes,
    )


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------

class TestZeroClaimLogging:
    """Zero-claim extraction logging observability (#707)."""

    @pytest.mark.asyncio
    async def test_zero_claim_logged_at_info_by_default(self, caplog):
        """Zero-claim result for valid input should log at INFO by default."""
        conv = [{"role": "user", "content": "I prefer dark mode for my editor."}]
        config = _make_config(extraction_zero_claim_log_level="INFO")

        with caplog.at_level(logging.INFO, logger="cortex.capture.wake_pipeline"):
            result = await _run(conv, config)

        assert result.zero_claim_warning is True
        assert result.ok is True
        info_records = [r for r in caplog.records if r.levelno == logging.INFO]
        assert any("zero claims" in r.message.lower() for r in info_records), (
            "Expected INFO log containing 'zero claims'"
        )

    @pytest.mark.asyncio
    async def test_zero_claim_log_no_preview_by_default(self, caplog):
        """Zero-claim log should NOT include content preview by default (PII protection)."""
        content = "This is my specific preference that should NOT be previewed."
        conv = [{"role": "user", "content": content}]
        config = _make_config(extraction_zero_claim_log_level="INFO")

        with caplog.at_level(logging.INFO, logger="cortex.capture.wake_pipeline"):
            await _run(conv, config)

        full_log = " ".join(r.message for r in caplog.records)
        # Content should NOT appear in the log
        assert content[:30] not in full_log, (
            f"Content preview leaked into log without opt-in. Got: {full_log!r}"
        )
        # Should include msg_count and content_len instead
        assert "msg_count=" in full_log, f"Expected msg_count in log. Got: {full_log!r}"
        assert "content_len=" in full_log, f"Expected content_len in log. Got: {full_log!r}"

    @pytest.mark.asyncio
    async def test_zero_claim_log_includes_preview_when_enabled(self, caplog):
        """Zero-claim log should include preview when extraction_zero_claim_log_preview=True."""
        content = "This is my specific preference that should be previewed."
        conv = [{"role": "user", "content": content}]
        config = _make_config(
            extraction_zero_claim_log_level="INFO",
            extraction_zero_claim_log_preview=True,
        )

        with caplog.at_level(logging.INFO, logger="cortex.capture.wake_pipeline"):
            await _run(conv, config)

        full_log = " ".join(r.message for r in caplog.records)
        assert content[:30] in full_log, (
            f"Expected message preview in log when enabled. Got: {full_log!r}"
        )

    @pytest.mark.asyncio
    async def test_zero_claim_log_level_warning_when_configured(self, caplog):
        """When extraction_zero_claim_log_level=WARNING, log at WARNING not INFO."""
        conv = [{"role": "user", "content": "Hello there, how are you doing today?"}]
        config = _make_config(extraction_zero_claim_log_level="WARNING")

        with caplog.at_level(logging.DEBUG, logger="cortex.capture.wake_pipeline"):
            await _run(conv, config)

        warning_records = [r for r in caplog.records if r.levelno == logging.WARNING]
        info_records = [r for r in caplog.records if r.levelno == logging.INFO
                        and "zero claims" in r.message.lower()]
        assert any("zero claims" in r.message.lower() for r in warning_records), (
            "Expected WARNING log for zero-claim result"
        )
        assert not info_records, "Should NOT emit INFO log when configured at WARNING"

    @pytest.mark.asyncio
    async def test_zero_claim_log_level_debug_when_configured(self, caplog):
        """When extraction_zero_claim_log_level=DEBUG, log at DEBUG."""
        conv = [{"role": "user", "content": "Sure, sounds good!"}]
        config = _make_config(extraction_zero_claim_log_level="DEBUG")

        with caplog.at_level(logging.DEBUG, logger="cortex.capture.wake_pipeline"):
            await _run(conv, config)

        debug_records = [r for r in caplog.records if r.levelno == logging.DEBUG
                         and "zero claims" in r.message.lower()]
        assert debug_records, "Expected DEBUG log for zero-claim result"

    @pytest.mark.asyncio
    async def test_zero_claim_counter_increments(self):
        """zero_claim_outcomes counter should increment on each zero-claim result."""
        conv = [{"role": "user", "content": "Thanks for the update."}]
        config = _make_config()

        result1 = await _run(conv, config, zero_claim_outcomes=0)
        assert result1.updated_zero_claim_outcomes == 1

        result2 = await _run(conv, config, zero_claim_outcomes=result1.updated_zero_claim_outcomes)
        assert result2.updated_zero_claim_outcomes == 2

    @pytest.mark.asyncio
    async def test_no_log_when_extraction_result_is_none(self, caplog):
        """When extractor is unavailable (result=None), no zero-claim log emitted."""
        conv = [{"role": "user", "content": "Some valid content."}]
        config = _make_config()

        # None extractor → errors path, no zero_claim_warning
        with caplog.at_level(logging.DEBUG, logger="cortex.capture.wake_pipeline"):
            result = await _run(conv, config, extractor=MagicMock(extract=None))

        # The extractor.extract is not an AsyncMock here, so it will fail —
        # use a proper None extractor flow instead
        # Reset: pass extractor=None so the plugin path sets extractor to None
        from cortex.capture.wake_pipeline import run_extraction
        result = await run_extraction(
            extractor=None,  # triggers "not available" error branch
            storage=AsyncMock(),
            llm=MagicMock(embed=AsyncMock(return_value=[0.1] * 128)),
            entity_resolver=None,
            cornerstone_guardian=None,
            reconciler=None,
            event_emitter=None,
            config=config,
            extraction_semaphore=asyncio.Semaphore(1),
            conversation=conv,
            sid="test-sid-707-none",
            db_session_pk=None,
            effective_owner_id="test-owner",
            episode_event_id=None,
            budget_check=lambda n: None,
            budget_consume=lambda n: None,
            errors=[],
            warnings=[],
            zero_claim_outcomes=0,
        )
        assert result.zero_claim_warning is False
        assert result.updated_zero_claim_outcomes == 0

    @pytest.mark.asyncio
    async def test_no_log_for_empty_conversation(self, caplog):
        """Empty conversation (no signal) should NOT trigger zero-claim log."""
        config = _make_config()
        conv = [{"role": "user", "content": ""}]

        with caplog.at_level(logging.DEBUG, logger="cortex.capture.wake_pipeline"):
            result = await _run(conv, config)

        assert result.zero_claim_warning is False
        assert result.updated_zero_claim_outcomes == 0

    @pytest.mark.asyncio
    async def test_zero_claim_log_level_critical_when_configured(self, caplog):
        """When extraction_zero_claim_log_level=CRITICAL, log at CRITICAL."""
        conv = [{"role": "user", "content": "Something important happened."}]
        config = _make_config(extraction_zero_claim_log_level="CRITICAL")

        with caplog.at_level(logging.DEBUG, logger="cortex.capture.wake_pipeline"):
            await _run(conv, config)

        critical_records = [r for r in caplog.records if r.levelno == logging.CRITICAL
                            and "zero claims" in r.message.lower()]
        assert critical_records, "Expected CRITICAL log for zero-claim result"

    @pytest.mark.asyncio
    async def test_zero_claim_invalid_log_level_falls_back_to_info(self, caplog):
        """Invalid log level (e.g. typo) should warn and default to INFO."""
        conv = [{"role": "user", "content": "Some user input for validation test."}]
        config = _make_config(extraction_zero_claim_log_level="WARNNG")  # typo

        with caplog.at_level(logging.DEBUG, logger="cortex.capture.wake_pipeline"):
            await _run(conv, config)

        # Should have a warning about invalid level
        warning_records = [r for r in caplog.records if r.levelno == logging.WARNING
                           and "invalid" in r.message.lower()
                           and "WARNNG" in r.message]
        assert warning_records, "Expected WARNING about invalid log level"

        # The zero-claim message itself should be at INFO (fallback)
        info_records = [r for r in caplog.records if r.levelno == logging.INFO
                        and "zero claims" in r.message.lower()]
        assert info_records, "Expected INFO fallback for zero-claim log"

    @pytest.mark.asyncio
    async def test_zero_claim_log_includes_noise_filtered_count(self, caplog):
        """Zero-claim log should include noise_filtered count for diagnostics."""
        conv = [{"role": "user", "content": "Sure! Got it. Sounds great."}]
        config = _make_config(extraction_zero_claim_log_level="INFO")
        # Simulate extractor that noise-filtered 3 messages but produced 0 claims
        extractor = _make_extractor(claims=[], noise_filtered=3)

        with caplog.at_level(logging.INFO, logger="cortex.capture.wake_pipeline"):
            await _run(conv, config, extractor=extractor)

        full_log = " ".join(r.message for r in caplog.records)
        assert "noise_filtered=3" in full_log, (
            f"Expected noise_filtered count in log. Got: {full_log!r}"
        )


# ---------------------------------------------------------------------------
# Config model test
# ---------------------------------------------------------------------------

class TestExtractionZeroClaimLogLevelConfig:
    """Config field extraction_zero_claim_log_level wiring."""

    def test_default_is_info(self):
        """Default value for extraction_zero_claim_log_level should be INFO."""
        from cortex.config.model import CortexConfig
        cfg = CortexConfig()
        assert cfg.extraction_zero_claim_log_level == "INFO"

    def test_from_raw_wires_zero_claim_log_level(self):
        """CortexConfig._from_raw should parse zero_claim_log_level from extraction section."""
        from cortex.config.model import CortexConfig
        cfg = CortexConfig._from_raw({
            "cortex": {
                "extraction": {
                    "zero_claim_log_level": "warning"
                }
            }
        })
        assert cfg.extraction_zero_claim_log_level == "WARNING"

    def test_from_raw_uppercases_log_level(self):
        """CortexConfig._from_raw should uppercase the log level value."""
        from cortex.config.model import CortexConfig
        cfg = CortexConfig._from_raw({
            "cortex": {
                "extraction": {
                    "zero_claim_log_level": "debug"
                }
            }
        })
        assert cfg.extraction_zero_claim_log_level == "DEBUG"

    def test_default_preview_is_false(self):
        """Default value for extraction_zero_claim_log_preview should be False."""
        from cortex.config.model import CortexConfig
        cfg = CortexConfig()
        assert cfg.extraction_zero_claim_log_preview is False

    def test_from_raw_wires_zero_claim_log_preview(self):
        """CortexConfig._from_raw should parse zero_claim_log_preview from extraction section."""
        from cortex.config.model import CortexConfig
        cfg = CortexConfig._from_raw({
            "cortex": {
                "extraction": {
                    "zero_claim_log_preview": True
                }
            }
        })
        assert cfg.extraction_zero_claim_log_preview is True
