# -*- coding: utf-8 -*-
"""STEP 6 tests — Claude client + ai.task ledger (single-shot).

    odoo -d <db> -u activeblue_familylaw --test-enable \
         --test-tags familylaw_step6 --stop-after-init

External calls are ALWAYS mocked (patch familylaw.ai.client._call_provider). Tests
never hit the network. We assert on what gets WRITTEN TO ODOO:
  * the document is born ai_draft;
  * an ai.task is logged with model + token cost + latency;
  * proposed citations are born unverified;
  * the per-task token ceiling caps max_tokens;
  * trust/billing routing RAISES before any provider call;
  * a provider error marks the task failed.
"""

from contextlib import contextmanager
from unittest.mock import patch

from odoo.tests.common import TransactionCase, new_test_user, tagged
from odoo.exceptions import UserError

CLIENT = "odoo.addons.activeblue_familylaw_v2.models.familylaw_ai.FamilyLawAIClient"


def _fake_response(text="DRAFT BODY", citations=None, pt=100, ct=200):
    return {
        "text": text,
        "usage": {"input_tokens": pt, "output_tokens": ct},
        "citations": citations or [],
    }


@tagged("post_install", "-at_install", "familylaw", "familylaw_step6")
class TestStep6AIClient(TransactionCase):

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.partner = cls.env["res.partner"].create({"name": "AI Client"})
        cls.case = cls.env["familylaw.case"].create({
            "name": "AI Matter",
            "client_id": cls.partner.id,
            "case_type": "support_modification",
        })
        cls.proc = cls.case.proceeding_ids[0]
        cls.Doc = cls.env["familylaw.document"]
        cls.Task = cls.env["familylaw.ai.task"]
        cls.attorney = new_test_user(
            cls.env, login="fl_atty6", name="Attorney 6",
            email="atty6@example.com",
            groups="base.group_user,activeblue_familylaw_v2.group_familylaw_attorney",
        )
        # configure a key + model so config-dependent code paths work
        icp = cls.env["ir.config_parameter"].sudo()
        icp.set_param("familylaw.anthropic_api_key", "test-key")
        icp.set_param("familylaw.model", "claude-sonnet-4-6")
        icp.set_param("familylaw.token_ceiling", "2048")

    # --- drafting produces an unapproved draft ------------------------------
    def test_01_document_born_ai_draft(self):
        with patch(CLIENT + "._call_provider", return_value=_fake_response()):
            doc = self.Doc.ai_assemble(self.proc, "pleading", "Draft a motion.")
        self.assertEqual(doc.state, "ai_draft")
        self.assertEqual(doc.source, "ai")
        self.assertIn("DRAFT BODY", doc.body)

    # --- ai.task logged with economics --------------------------------------
    def test_02_task_logged_with_cost_and_latency(self):
        with patch(CLIENT + "._call_provider",
                   return_value=_fake_response(pt=100, ct=200)):
            doc = self.Doc.ai_assemble(self.proc, "pleading", "Draft a motion.")
        task = self.Task.search([("document_id", "=", doc.id)], limit=1)
        self.assertTrue(task)
        self.assertEqual(task.state, "done")
        self.assertEqual(task.model_used, "claude-sonnet-4-6")
        self.assertEqual(task.prompt_tokens, 100)
        self.assertEqual(task.completion_tokens, 200)
        self.assertEqual(task.total_tokens, 300)
        self.assertGreater(task.cost_usd, 0.0)
        self.assertGreaterEqual(task.latency_ms, 0)

    # --- citations born unverified ------------------------------------------
    def test_03_citations_born_unverified(self):
        cites = [{"citation_text": "Smith v. Jones, 1 So.3d 2 (Fla. 3d DCA 2010)",
                  "proposition": "X"}]
        with patch(CLIENT + "._call_provider",
                   return_value=_fake_response(citations=cites)):
            doc = self.Doc.ai_assemble(self.proc, "pleading", "Draft with authority.")
        self.assertEqual(len(doc.citation_ids), 1)
        self.assertEqual(doc.citation_ids.status, "unverified")
        self.assertFalse(doc.citation_ids.is_verified)

    # --- token ceiling caps max_tokens --------------------------------------
    def test_04_token_ceiling_caps_max_tokens(self):
        captured = {}

        def _spy(self2, provider, model, messages, max_tokens, system=None):
            captured["max_tokens"] = max_tokens
            return _fake_response()

        with patch(CLIENT + "._call_provider", _spy):
            # request far above the ceiling (2048)
            self.env["familylaw.ai.client"].generate(
                "draft_document", [{"role": "user", "content": "hi"}],
                max_tokens=999999,
            )
        self.assertEqual(captured["max_tokens"], 2048)

    # --- trust/billing never reaches a model --------------------------------
    def test_05_trust_billing_blocked_before_call(self):
        calls = {"n": 0}

        def _spy(*a, **k):
            calls["n"] += 1
            return _fake_response()

        with patch(CLIENT + "._call_provider", _spy):
            with self.assertRaises(UserError):
                self.env["familylaw.ai.client"].generate(
                    "trust_billing", [{"role": "user", "content": "reconcile"}],
                )
        self.assertEqual(calls["n"], 0, "Provider must NOT be called for trust/billing.")

    def test_06_route_model_raises_for_iota(self):
        with self.assertRaises(UserError):
            self.env["familylaw.ai.client"]._route_model("iota")

    # --- provider failure PROPAGATES (never silently swallowed) -------------
    def test_07_provider_error_propagates(self):
        """A provider error is re-raised so nothing downstream proceeds on a
        failed/empty result."""
        def _boom(*a, **k):
            raise ValueError("network down")

        # patch durable logging to a no-op here — its real (separate-cursor) path
        # can't see the test's uncommitted case; this test only asserts propagation.
        with patch(CLIENT + "._call_provider", _boom), \
             patch(CLIENT + "._log_failure_durably", lambda *a, **k: None):
            with self.assertRaises(ValueError):
                self.env["familylaw.ai.client"].generate(
                    "draft_document", [{"role": "user", "content": "hi"}],
                    case=self.case,
                )

    # --- durable failure logging --------------------------------------------
    def test_11_durable_failure_log_writes_row(self):
        """_log_failure_durably writes a 'failed' ai.task with the given metadata.

        Production uses an independent cursor so the row survives rollback of the
        caller; Odoo's TransactionCase neuters real commits for isolation, so here we
        point the registry cursor at the test transaction to verify the WRITE LOGIC
        (fields + case linkage). The separate-transaction durability is the standard
        Odoo new-cursor pattern."""
        @contextmanager
        def _test_cursor():
            yield self.env.cr  # reuse the test transaction (sees the test's case)

        marker = "boom-durable"
        # neutralize the real commit() — forbidden inside a test transaction
        with patch.object(self.env.registry, "cursor", _test_cursor), \
             patch.object(self.env.cr, "commit", lambda *a, **k: None):
            self.env["familylaw.ai.client"]._log_failure_durably({
                "name": "AI: draft_document",
                "task_type": "draft_document",
                "provider": "anthropic",
                "model_used": "claude-sonnet-4-6",
                "state": "failed",
                "case_id": self.case.id,
                "error_message": marker,
                "latency_ms": 5,
            })
        task = self.Task.search([("state", "=", "failed"),
                                 ("error_message", "=", marker)])
        self.assertTrue(task, "durable failure row should be written")
        self.assertEqual(task.case_id, self.case)

    def test_12_generate_durably_logs_on_error(self):
        """On a provider error, generate routes the failure to durable logging with
        the right metadata (and still re-raises)."""
        captured = []

        def _boom(*a, **k):
            raise ValueError("network down")

        def _spy(self2, vals):
            captured.append(vals)

        with patch(CLIENT + "._call_provider", _boom), \
             patch(CLIENT + "._log_failure_durably", _spy):
            with self.assertRaises(ValueError):
                self.env["familylaw.ai.client"].generate(
                    "draft_document", [{"role": "user", "content": "hi"}],
                    case=self.case,
                )
        self.assertEqual(len(captured), 1)
        self.assertEqual(captured[0]["state"], "failed")
        self.assertIn("network down", captured[0]["error_message"])
        self.assertEqual(captured[0]["case_id"], self.case.id)

    # --- cost estimate is deterministic -------------------------------------
    def test_08_cost_estimate(self):
        client = self.env["familylaw.ai.client"]
        # sonnet: in 3.0 / out 15.0 per 1M
        cost = client._estimate_cost("claude-sonnet-4-6", 1_000_000, 1_000_000)
        self.assertAlmostEqual(cost, 18.0, places=4)

    # --- missing API key raises (config gate) -------------------------------
    def test_09_missing_key_raises(self):
        self.env["ir.config_parameter"].sudo().set_param(
            "familylaw.anthropic_api_key", "")
        with self.assertRaises(UserError):
            self.env["familylaw.ai.client"]._get_api_key()

    # --- ai draft is NOT auto-approved (Gate 1 intact) ----------------------
    def test_10_ai_draft_not_auto_approved(self):
        with patch(CLIENT + "._call_provider", return_value=_fake_response()):
            doc = self.Doc.ai_assemble(self.proc, "pleading", "Draft.")
        self.assertNotEqual(doc.state, "approved")
        # even a real attorney cannot file it while unapproved (Gate 1)
        with self.assertRaises(UserError):
            doc.with_user(self.attorney).action_mark_filed()
