import ast
import asyncio
import hashlib
import json
from pathlib import Path
import time as real_time
import unittest

import httpx


SOURCE = Path(__file__).resolve().parents[1] / "lev_server.py"


def load_recovery_namespace():
    tree = ast.parse(SOURCE.read_text(encoding="utf-8"), filename=str(SOURCE))
    wanted = {"TurnReply", "_recovery_failure", "_recovery_context", "turn"}
    constants = {
        "RECOVERY_DEADLINE_SECONDS",
        "MAX_MODEL_ATTEMPTS",
        "SAFE_TERMINAL_FALLBACK",
        "RECOVERY_HANDOFF_REPLY",
    }
    body = []
    base_turn_found = False
    for node in tree.body:
        if isinstance(node, (ast.Assign, ast.AnnAssign)):
            names = (
                [target.id for target in node.targets if isinstance(target, ast.Name)]
                if isinstance(node, ast.Assign)
                else [node.target.id] if isinstance(node.target, ast.Name) else []
            )
            if any(name in constants for name in names):
                body.append(node)
        elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and node.name in wanted:
            if node.name != "turn" or not base_turn_found:
                body.append(node)
            if node.name == "turn":
                base_turn_found = True
    namespace = {
        "asyncio": asyncio,
        "httpx": httpx,
        "json": json,
        "hashlib": hashlib,
        "time": real_time,
        "facts_get": lambda _client_id: None,
        "_turn_diagnostic": lambda *_args, **_kwargs: None,
    }
    exec(compile(ast.fix_missing_locations(ast.Module(body=body, type_ignores=[])), str(SOURCE), "exec"), namespace)
    return namespace


class RecoveryTests(unittest.IsolatedAsyncioTestCase):
    def setUp(self):
        self.ns = load_recovery_namespace()
        self.histories = {}
        self.calls = 0
        self.ns["load_history"] = lambda chat_id: list(self.histories.get(chat_id, []))
        self.ns["save_history"] = lambda chat_id, history: self.histories.__setitem__(chat_id, list(history))
        self.ns["_fmt"] = lambda template, **kwargs: template.format(**kwargs)
        self.ns["KNOWN_CLIENT_TEMPLATE"] = "{facts}"
        self.ns["FACT_NAME_LABEL"] = "name"
        self.ns["FACT_PHONE_LABEL"] = "phone"

    def set_answers(self, answers):
        async def ask_lev(_history, _context):
            self.calls += 1
            answer = answers[self.calls - 1]
            if isinstance(answer, BaseException):
                raise answer
            return answer

        self.ns["ask_lev"] = ask_lev

    async def test_success_on_thirteenth_attempt_keeps_one_history_pair(self):
        self.set_answers([""] * 12 + ["Готовый ответ"])
        reply = await self.ns["turn"]("chat-13", "сообщение")
        self.assertEqual("Готовый ответ", str(reply))
        self.assertEqual(13, self.calls)
        self.assertFalse(reply.recovery_exhausted)
        self.assertEqual(
            [{"role": "user", "content": "сообщение"}, {"role": "assistant", "content": "Готовый ответ"}],
            self.histories["chat-13"],
        )

    async def test_twenty_empty_answers_do_not_start_attempt_twenty_one(self):
        self.set_answers([""] * 25)
        reply = await self.ns["turn"]("chat-empty", "сообщение")
        self.assertEqual(20, self.calls)
        self.assertEqual(self.ns["SAFE_TERMINAL_FALLBACK"], str(reply))
        self.assertTrue(reply.recovery_exhausted)
        self.assertEqual(2, len(self.histories["chat-empty"]))

    async def test_deadline_covers_first_call_and_prevents_another_attempt(self):
        self.ns["RECOVERY_DEADLINE_SECONDS"] = 0.02

        async def ask_lev(_history, _context):
            self.calls += 1
            await asyncio.sleep(0.04)
            return "поздно"

        self.ns["ask_lev"] = ask_lev
        reply = await self.ns["turn"]("chat-deadline", "сообщение")
        self.assertEqual(1, self.calls)
        self.assertEqual("deadline", reply.recovery_reason)
        self.assertEqual(self.ns["SAFE_TERMINAL_FALLBACK"], str(reply))

    async def test_permanent_unauthorized_stops_immediately(self):
        request = httpx.Request("POST", "https://example.test/chat/completions")
        response = httpx.Response(401, request=request)
        self.set_answers([httpx.HTTPStatusError("unauthorized", request=request, response=response)] * 20)
        reply = await self.ns["turn"]("chat-401", "сообщение")
        self.assertEqual(1, self.calls)
        self.assertEqual("http_401", reply.recovery_reason)
        self.assertEqual(self.ns["SAFE_TERMINAL_FALLBACK"], str(reply))

    async def test_retry_after_waits_within_the_same_deadline_budget(self):
        self.ns["RECOVERY_DEADLINE_SECONDS"] = 0.04
        request = httpx.Request("POST", "https://example.test/chat/completions")
        response = httpx.Response(429, headers={"retry-after": "8"}, request=request)
        self.set_answers([httpx.HTTPStatusError("busy", request=request, response=response)] * 20)
        reply = await self.ns["turn"]("chat-retry-after", "сообщение")
        self.assertEqual(1, self.calls)
        self.assertEqual("deadline", reply.recovery_reason)
        self.assertEqual(self.ns["SAFE_TERMINAL_FALLBACK"], str(reply))

    def test_source_contract_is_120_seconds_and_twenty_attempts(self):
        self.assertEqual(120, self.ns["RECOVERY_DEADLINE_SECONDS"])
        self.assertEqual(20, self.ns["MAX_MODEL_ATTEMPTS"])


if __name__ == "__main__":
    unittest.main()
