"""Integration tests for PATCH /ai-sessions/{sid}/suggested-fixes/{fid}/outcome.

Fixture style follows test_session_suggested_fixes_api.py:
  client, test_user, auth_headers, test_db
"""
from __future__ import annotations

from unittest.mock import AsyncMock, call, patch

import pytest
from httpx import AsyncClient
from sqlalchemy import select

from app.api.endpoints.session_suggested_fixes import _clear_preview_cache_for_tests
from app.models.ai_session import AISession
from app.models.session_suggested_fix import SessionSuggestedFix


@pytest.fixture(autouse=True)
def _isolate_preview_cache():
    _clear_preview_cache_for_tests()
    yield
    _clear_preview_cache_for_tests()


# ── shared helper ────────────────────────────────────────────────────────────

async def _make_session_with_fix(test_db, user) -> tuple[str, str]:
    """Create an AISession + active proposed SessionSuggestedFix.

    Returns (session_id_str, fix_id_str).
    """
    session = AISession(
        user_id=user["user_data"]["id"],
        account_id=user["user_data"]["account_id"],
        session_type="chat",
        intake_type="free_text",
        intake_content={"text": "outcome test"},
        status="active",
        confidence_tier="discovery",
        conversation_messages=[],
    )
    test_db.add(session)
    await test_db.flush()

    fix = SessionSuggestedFix(
        session_id=session.id,
        account_id=session.account_id,
        title="Reset credential cache",
        description="Clear stale credentials from the domain cache.",
        confidence_pct=82,
    )
    test_db.add(fix)
    await test_db.commit()
    await test_db.refresh(fix)

    return str(session.id), str(fix.id)


# ── tests ────────────────────────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_patch_outcome_marks_success(
    client: AsyncClient, test_user, auth_headers, test_db
):
    session_id, fix_id = await _make_session_with_fix(test_db, test_user)

    r = await client.patch(
        f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
        headers=auth_headers,
        json={"outcome": "applied_success"},
    )
    assert r.status_code == 200, r.text
    body = r.json()
    assert body["status"] == "applied_success"
    assert body["verified_at"] is not None


@pytest.mark.asyncio
async def test_patch_outcome_partial_requires_notes(
    client: AsyncClient, test_user, auth_headers, test_db
):
    session_id, fix_id = await _make_session_with_fix(test_db, test_user)

    r = await client.patch(
        f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
        headers=auth_headers,
        json={"outcome": "applied_partial"},
    )
    assert r.status_code == 400
    assert "notes" in r.text.lower()


@pytest.mark.asyncio
async def test_partial_to_success_allowed(
    client: AsyncClient, test_user, auth_headers, test_db
):
    session_id, fix_id = await _make_session_with_fix(test_db, test_user)

    r1 = await client.patch(
        f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
        headers=auth_headers,
        json={"outcome": "applied_partial", "notes": "ran cred clear only"},
    )
    assert r1.status_code == 200, r1.text

    r2 = await client.patch(
        f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
        headers=auth_headers,
        json={"outcome": "applied_success"},
    )
    assert r2.status_code == 200
    assert r2.json()["status"] == "applied_success"


@pytest.mark.asyncio
async def test_terminal_outcome_is_locked(
    client: AsyncClient, test_user, auth_headers, test_db
):
    session_id, fix_id = await _make_session_with_fix(test_db, test_user)

    r1 = await client.patch(
        f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
        headers=auth_headers,
        json={"outcome": "applied_failed", "notes": "no change"},
    )
    assert r1.status_code == 200

    r2 = await client.patch(
        f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
        headers=auth_headers,
        json={"outcome": "applied_success"},
    )
    assert r2.status_code == 409


@pytest.mark.asyncio
async def test_partial_notes_can_be_updated(
    client: AsyncClient, test_user, auth_headers, test_db
):
    """partial→partial with new notes updates the stored notes."""
    session_id, fix_id = await _make_session_with_fix(test_db, test_user)

    r1 = await client.patch(
        f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
        json={"outcome": "applied_partial", "notes": "ran cred clear only"},
        headers=auth_headers,
    )
    assert r1.status_code == 200
    assert r1.json()["partial_notes"] == "ran cred clear only"

    r2 = await client.patch(
        f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
        json={"outcome": "applied_partial", "notes": "also finished the rebuild; not verified yet"},
        headers=auth_headers,
    )
    assert r2.status_code == 200
    assert r2.json()["partial_notes"] == "also finished the rebuild; not verified yet"


@pytest.mark.asyncio
async def test_dismissed_sets_no_timestamps(
    client: AsyncClient, test_user, auth_headers, test_db
):
    """dismissed outcome does not stamp applied_at or verified_at."""
    session_id, fix_id = await _make_session_with_fix(test_db, test_user)
    r = await client.patch(
        f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
        json={"outcome": "dismissed"},
        headers=auth_headers,
    )
    assert r.status_code == 200
    body = r.json()
    assert body["status"] == "dismissed"
    assert body["applied_at"] is None
    assert body["verified_at"] is None


@pytest.mark.asyncio
async def test_applied_at_auto_stamped_on_first_outcome(
    client: AsyncClient, test_user, auth_headers, test_db
):
    """If applied_at is null when the engineer sets outcome, server stamps it."""
    session_id, fix_id = await _make_session_with_fix(test_db, test_user)
    r = await client.patch(
        f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
        json={"outcome": "applied_success"},
        headers=auth_headers,
    )
    assert r.status_code == 200
    body = r.json()
    assert body["applied_at"] is not None
    assert body["verified_at"] is not None


@pytest.mark.asyncio
async def test_failed_outcome_stores_notes_as_failure_reason(
    client: AsyncClient, test_user, auth_headers, test_db
):
    """applied_failed stores notes under failure_reason (not partial_notes)."""
    session_id, fix_id = await _make_session_with_fix(test_db, test_user)
    r = await client.patch(
        f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
        json={"outcome": "applied_failed", "notes": "user reports no change"},
        headers=auth_headers,
    )
    assert r.status_code == 200
    body = r.json()
    assert body["failure_reason"] == "user reports no change"
    assert body["partial_notes"] is None


# ── state_version bump ────────────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_outcome_patch_bumps_state_version(
    client: AsyncClient, test_user, auth_headers, test_db
):
    """PATCH /outcome must increment ai_sessions.state_version (like record_decision)."""
    session_id, fix_id = await _make_session_with_fix(test_db, test_user)

    # Capture the initial state_version from DB.
    from uuid import UUID
    result = await test_db.execute(
        select(AISession).where(AISession.id == UUID(session_id))
    )
    session_obj = result.scalar_one()
    initial_version = session_obj.state_version

    r = await client.patch(
        f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
        json={"outcome": "applied_success"},
        headers=auth_headers,
    )
    assert r.status_code == 200

    await test_db.refresh(session_obj)
    assert session_obj.state_version == initial_version + 1, (
        "Outcome patch must bump state_version so preview cache is invalidated"
    )


# ── outcome propagation into preview bundle ───────────────────────────────────

@pytest.mark.asyncio
async def test_resolution_note_preview_reflects_outcome_after_patch(
    client: AsyncClient, test_user, auth_headers, test_db
):
    """End-to-end: preview before outcome != preview after outcome; new preview
    bundle includes failure_reason; state_version was bumped between the two.

    The LLM is stubbed so the test is deterministic. The stub returns whatever
    the user-message content is, which means the captured call args reflect
    what the bundle actually contained.
    """
    session_id, fix_id = await _make_session_with_fix(test_db, test_user)

    distinct_failure_reason = "DISTINCT-FAILURE-REASON-XYZZY-42"

    calls_made: list[str] = []

    async def fake_generate_text(system_prompt, messages, max_tokens):
        user_content = messages[0]["content"]
        calls_made.append(user_content)
        # Return markdown that includes the user-message bundle verbatim so we
        # can assert the bundle shape without inspecting mock internals.
        return (
            f"## Problem\ntest\n\n## What we confirmed\n(none)\n\n"
            f"## Root cause\ntest\n\n## Resolution\nBUNDLE_CONTENT={user_content}",
            100,
            50,
        )

    fake_provider = AsyncMock()
    fake_provider.generate_text = AsyncMock(side_effect=fake_generate_text)

    with patch(
        "app.services.resolution_note_generator.get_ai_provider",
        return_value=fake_provider,
    ):
        # Preview A — before any outcome recorded (status = "proposed").
        r_a = await client.post(
            f"/api/v1/ai-sessions/{session_id}/resolution-note/preview",
            headers=auth_headers,
        )
        assert r_a.status_code == 200
        markdown_a = r_a.json()["markdown"]
        version_a = r_a.json()["state_version"]
        assert r_a.json()["from_cache"] is False

        # Record an applied_failed outcome with a distinctive reason.
        r_patch = await client.patch(
            f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
            json={"outcome": "applied_failed", "notes": distinct_failure_reason},
            headers=auth_headers,
        )
        assert r_patch.status_code == 200

        # Preview B — must be a cache miss because state_version changed.
        r_b = await client.post(
            f"/api/v1/ai-sessions/{session_id}/resolution-note/preview",
            headers=auth_headers,
        )
        assert r_b.status_code == 200
        markdown_b = r_b.json()["markdown"]
        version_b = r_b.json()["state_version"]
        assert r_b.json()["from_cache"] is False, (
            "Preview after outcome patch must be a cache miss (state_version changed)"
        )

    # State version increased between the two previews.
    assert version_b > version_a, (
        f"state_version should have increased; got {version_a} → {version_b}"
    )

    # Markdown differs between the two previews.
    assert markdown_a != markdown_b, (
        "Regenerated preview after outcome patch should differ from pre-outcome preview"
    )

    # The bundle passed to the LLM for preview B includes the outcome fields.
    assert len(calls_made) == 2, f"Expected 2 LLM calls (one per preview); got {len(calls_made)}"
    bundle_b = calls_made[1]
    assert "applied_failed" in bundle_b, (
        "Bundle for second preview should include 'Outcome status: applied_failed'"
    )
    assert distinct_failure_reason in bundle_b, (
        "Bundle for second preview should include the failure_reason text"
    )


# ── Apply endpoint ─────────────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_apply_stamps_applied_at(
    client: AsyncClient, test_user, auth_headers, test_db
):
    """POST /apply stamps applied_at and bumps state_version."""
    from uuid import UUID
    session_id, fix_id = await _make_session_with_fix(test_db, test_user)

    result = await test_db.execute(
        select(AISession).where(AISession.id == UUID(session_id))
    )
    session_obj = result.scalar_one()
    initial_version = session_obj.state_version

    r = await client.post(
        f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
        headers=auth_headers,
    )
    assert r.status_code == 200, r.text
    body = r.json()
    assert body["applied_at"] is not None, "applied_at must be set after /apply"
    assert body["status"] == "proposed", "status must remain 'proposed' after /apply"

    await test_db.refresh(session_obj)
    assert session_obj.state_version == initial_version + 1, (
        "/apply must bump state_version so preview cache is invalidated"
    )


@pytest.mark.asyncio
async def test_apply_is_idempotent(
    client: AsyncClient, test_user, auth_headers, test_db
):
    """Second POST /apply returns 200 with applied_at unchanged (no double-bump)."""
    from uuid import UUID
    session_id, fix_id = await _make_session_with_fix(test_db, test_user)

    r1 = await client.post(
        f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
        headers=auth_headers,
    )
    assert r1.status_code == 200, r1.text
    applied_at_first = r1.json()["applied_at"]

    result = await test_db.execute(
        select(AISession).where(AISession.id == UUID(session_id))
    )
    session_obj = result.scalar_one()
    version_after_first = session_obj.state_version

    r2 = await client.post(
        f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
        headers=auth_headers,
    )
    assert r2.status_code == 200, r2.text
    assert r2.json()["applied_at"] == applied_at_first, (
        "applied_at must not change on second /apply call"
    )

    await test_db.refresh(session_obj)
    assert session_obj.state_version == version_after_first, (
        "state_version must not be bumped a second time on idempotent /apply"
    )


@pytest.mark.asyncio
async def test_apply_rejects_non_proposed(
    client: AsyncClient, test_user, auth_headers, test_db
):
    """POST /apply returns 409 when fix status is 'applied_success'."""
    session_id, fix_id = await _make_session_with_fix(test_db, test_user)

    # Advance the fix to a terminal status via the outcome endpoint.
    r_outcome = await client.patch(
        f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
        headers=auth_headers,
        json={"outcome": "applied_success"},
    )
    assert r_outcome.status_code == 200

    r = await client.post(
        f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
        headers=auth_headers,
    )
    assert r.status_code == 409, r.text


@pytest.mark.asyncio
async def test_apply_rejects_dismissed(
    client: AsyncClient, test_user, auth_headers, test_db
):
    """POST /apply returns 409 when fix status is 'dismissed'."""
    session_id, fix_id = await _make_session_with_fix(test_db, test_user)

    r_outcome = await client.patch(
        f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
        headers=auth_headers,
        json={"outcome": "dismissed"},
    )
    assert r_outcome.status_code == 200

    r = await client.post(
        f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
        headers=auth_headers,
    )
    assert r.status_code == 409, r.text


# ── AI outcome proposal: clear / reject ───────────────────────────────────────

async def _make_session_with_fix_and_proposal(test_db, user) -> tuple[str, str]:
    """Create an AISession + fix with a populated ai_outcome_proposal."""
    from uuid import UUID as _UUID
    session = AISession(
        user_id=user["user_data"]["id"],
        account_id=user["user_data"]["account_id"],
        session_type="chat",
        intake_type="free_text",
        intake_content={"text": "proposal clear test"},
        status="active",
        confidence_tier="discovery",
        conversation_messages=[],
    )
    test_db.add(session)
    await test_db.flush()

    fix = SessionSuggestedFix(
        session_id=session.id,
        account_id=session.account_id,
        title="Flush DNS cache",
        description="Run ipconfig /flushdns on the affected host.",
        confidence_pct=74,
        ai_outcome_proposal={"fix_id": str(session.id), "outcome": "success", "reason": "User confirmed resolved"},
    )
    test_db.add(fix)
    await test_db.commit()
    await test_db.refresh(fix)

    return str(session.id), str(fix.id)


@pytest.mark.asyncio
async def test_outcome_patch_clears_ai_proposal(
    client: AsyncClient, test_user, auth_headers, test_db
):
    """PATCH /outcome clears ai_outcome_proposal regardless of which outcome is written."""
    session_id, fix_id = await _make_session_with_fix_and_proposal(test_db, test_user)

    # Verify the proposal is set before the patch.
    from uuid import UUID
    result = await test_db.execute(
        select(SessionSuggestedFix).where(SessionSuggestedFix.id == UUID(fix_id))
    )
    fix_before = result.scalar_one()
    assert fix_before.ai_outcome_proposal is not None

    r = await client.patch(
        f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
        headers=auth_headers,
        json={"outcome": "applied_success"},
    )
    assert r.status_code == 200, r.text
    body = r.json()
    assert body["ai_outcome_proposal"] is None, (
        "PATCH /outcome must clear ai_outcome_proposal on any terminal action"
    )


@pytest.mark.asyncio
async def test_delete_ai_proposal_clears_field(
    client: AsyncClient, test_user, auth_headers, test_db
):
    """DELETE /ai-outcome-proposal clears the field without changing status."""
    session_id, fix_id = await _make_session_with_fix_and_proposal(test_db, test_user)

    r = await client.delete(
        f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/ai-outcome-proposal",
        headers=auth_headers,
    )
    assert r.status_code == 200, r.text
    body = r.json()
    assert body["ai_outcome_proposal"] is None, (
        "DELETE /ai-outcome-proposal must clear the field"
    )
    assert body["status"] == "proposed", (
        "DELETE /ai-outcome-proposal must not change fix status"
    )


@pytest.mark.asyncio
async def test_delete_ai_proposal_when_none_is_idempotent(
    client: AsyncClient, test_user, auth_headers, test_db
):
    """DELETE /ai-outcome-proposal returns 200 even when the field is already null."""
    session_id, fix_id = await _make_session_with_fix(test_db, test_user)

    # Fix created by _make_session_with_fix has ai_outcome_proposal=None.
    r = await client.delete(
        f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/ai-outcome-proposal",
        headers=auth_headers,
    )
    assert r.status_code == 200, r.text
    assert r.json()["ai_outcome_proposal"] is None
