import base64
import io
import json

import pytest

from src.gameplay import recovery as recoveryModule
from src.gameplay.recovery import smartRecovery


@pytest.fixture(autouse=True)
def resetLlmState(mocker):
    mocker.patch.dict(recoveryModule._LLM_STATE, {
        'probed': False, 'baseUrl': None, 'model': None,
    })


class _Orchestrator:
    def __init__(self, rootName='walkToWaypoint'):
        self.rootTask = (
            type('Task', (), {'name': rootName})() if rootName else None)
        self.resetCount = 0

    def reset(self):
        self.resetCount += 1


def _context(rootName='walkToWaypoint', attacking=False, paused=False):
    return {
        'ng_pause': paused,
        'ng_screenshot': None,
        'ng_radar': {'coordinate': [32400, 32230, 7]},
        'ng_cave': {
            'enabled': True,
            'isAttackingSomeCreature': attacking,
            'waypoints': {
                'items': [
                    {'label': 'Hunt', 'coordinate': [32410, 32230, 7]},
                    {'label': 'Start', 'coordinate': [32400, 32220, 7]},
                    {'label': 'Shop', 'coordinate': [32390, 32230, 7]},
                ],
                'currentIndex': 0,
                'state': None,
            },
        },
        'gameWindow': {'coordinate': (0, 0, 0, 0)},
        'ng_tasksOrchestrator': _Orchestrator(rootName),
    }


def _advance(mocker, context, seconds):
    mocker.patch.object(recoveryModule, 'monotonic', return_value=seconds)
    return smartRecovery(context)


def _blockDetour(mocker):
    mocker.patch.object(
        recoveryModule, 'isCoordinateWalkable', return_value=False)


def test_nudge_after_twenty_seconds(mocker):
    _blockDetour(mocker)
    pressSpy = mocker.patch.object(recoveryModule, 'press')
    logSpy = mocker.patch.object(recoveryModule, 'logEvent')
    context = _context()

    _advance(mocker, context, 0)
    _advance(mocker, context, 21)

    assert pressSpy.call_args[0][0] in ('left', 'down', 'right', 'up')
    assert context['ng_tasksOrchestrator'].resetCount == 1
    assert logSpy.call_args[0][0] == 'recoveryNudge'


def test_detour_clicks_walkable_flank(mocker):
    mocker.patch.object(
        recoveryModule, 'isCoordinateWalkable', return_value=True)
    clickSpy = mocker.patch.object(recoveryModule, 'clickSlot')
    logSpy = mocker.patch.object(recoveryModule, 'logEvent')
    context = _context()

    _advance(mocker, context, 0)
    _advance(mocker, context, 41)

    clickSpy.assert_called_once()
    assert logSpy.call_args[0][0] == 'recoveryDetour'


def test_skip_waypoint_after_sixty_seconds(mocker):
    _blockDetour(mocker)
    mocker.patch.object(recoveryModule, 'press')
    logSpy = mocker.patch.object(recoveryModule, 'logEvent')
    context = _context()

    for seconds in (0, 21, 41, 61):
        _advance(mocker, context, seconds)

    assert context['ng_cave']['waypoints']['currentIndex'] == 1
    assert logSpy.call_args[0][0] == 'recoverySkip'


class _FakeResponse(io.BytesIO):
    def __enter__(self):
        return self

    def __exit__(self, *args):
        return False


def _llmResponse(direction, reason):
    body = json.dumps({'message': {'content': json.dumps({
        'direction': direction, 'reason': reason,
    })}}).encode('utf-8')
    return _FakeResponse(body)


def test_llm_hint_walks_suggested_direction(mocker, monkeypatch):
    import numpy as np
    _blockDetour(mocker)
    monkeypatch.delenv('TIBIAPILOT_LLM_URL', raising=False)
    pressSpy = mocker.patch.object(recoveryModule, 'press')
    logSpy = mocker.patch.object(recoveryModule, 'logEvent')
    submitSpy = mocker.patch.object(recoveryModule, 'submit', return_value=1)
    mocker.patch.object(
        recoveryModule, 'poll',
        return_value={'direction': 'north', 'reason': 'wall to the east'})
    context = _context()
    context['ng_screenshot'] = np.zeros((20, 20), dtype=np.uint8)

    for seconds in (0, 21, 41, 61, 91, 92):
        _advance(mocker, context, seconds)

    submitSpy.assert_called_once()
    assert pressSpy.call_count == 4  # 1x Nudge + 3x LLM-Richtung
    pressSpy.assert_called_with('up')
    assert logSpy.call_args[0][0] == 'recoveryLlmHint'


def test_llm_step_is_skipped_without_server(mocker, monkeypatch):
    _blockDetour(mocker)
    monkeypatch.delenv('TIBIAPILOT_LLM_URL', raising=False)
    mocker.patch.object(recoveryModule, 'press')
    submitSpy = mocker.patch.object(recoveryModule, 'submit')
    context = _context()

    for seconds in (0, 21, 41, 61, 91):
        _advance(mocker, context, seconds)

    submitSpy.assert_not_called()


def test_route_restart_goes_to_start_label(mocker, monkeypatch):
    _blockDetour(mocker)
    monkeypatch.delenv('TIBIAPILOT_LLM_URL', raising=False)
    mocker.patch.object(recoveryModule, 'press')
    logSpy = mocker.patch.object(recoveryModule, 'logEvent')
    context = _context()

    for seconds in (0, 21, 41, 61, 91, 121):
        _advance(mocker, context, seconds)

    assert context['ng_cave']['waypoints']['currentIndex'] == 1
    assert logSpy.call_args[0][0] == 'recoveryRestart'


def test_give_up_pauses_the_bot(mocker, monkeypatch):
    _blockDetour(mocker)
    monkeypatch.delenv('TIBIAPILOT_LLM_URL', raising=False)
    mocker.patch.object(recoveryModule, 'press')
    logSpy = mocker.patch.object(recoveryModule, 'logEvent')
    context = _context()

    for seconds in (0, 21, 41, 61, 91, 121, 151):
        _advance(mocker, context, seconds)

    assert context['ng_pause'] is True
    assert logSpy.call_args[0][0] == 'recoveryGiveUp'


def test_moving_resets_the_ladder(mocker):
    _blockDetour(mocker)
    pressSpy = mocker.patch.object(recoveryModule, 'press')
    context = _context()

    _advance(mocker, context, 0)
    _advance(mocker, context, 21)
    context['ng_radar']['coordinate'] = [32401, 32230, 7]
    _advance(mocker, context, 40)
    _advance(mocker, context, 60)

    assert pressSpy.call_count == 1
    assert context['ng_cave']['waypoints']['currentIndex'] == 0
    assert context['ng_recovery']['level'] == 0


def test_attacking_is_no_stuck(mocker):
    _blockDetour(mocker)
    pressSpy = mocker.patch.object(recoveryModule, 'press')
    context = _context(attacking=True)

    for seconds in (0, 30, 60, 90, 151):
        _advance(mocker, context, seconds)

    pressSpy.assert_not_called()
    assert context['ng_pause'] is False


def test_npc_actions_are_no_stuck(mocker):
    _blockDetour(mocker)
    pressSpy = mocker.patch.object(recoveryModule, 'press')
    context = _context(rootName='bbotBuy')

    for seconds in (0, 30, 151):
        _advance(mocker, context, seconds)

    pressSpy.assert_not_called()


def test_skip_with_none_index_starts_at_zero(mocker):
    _blockDetour(mocker)
    mocker.patch.object(recoveryModule, 'press')
    mocker.patch.object(recoveryModule, 'logEvent')
    context = _context()
    context['ng_cave']['waypoints']['currentIndex'] = None

    for seconds in (0, 21, 41, 61):
        _advance(mocker, context, seconds)

    assert context['ng_cave']['waypoints']['currentIndex'] == 0


def test_auto_probe_discovers_local_ollama(mocker, monkeypatch):
    import numpy as np
    _blockDetour(mocker)
    monkeypatch.delenv('TIBIAPILOT_LLM_URL', raising=False)
    monkeypatch.delenv('TIBIAPILOT_LLM_MODEL', raising=False)
    pressSpy = mocker.patch.object(recoveryModule, 'press')
    logSpy = mocker.patch.object(recoveryModule, 'logEvent')
    tagsResponse = _FakeResponse(json.dumps(
        {'models': [{'name': 'qwen2.5vl:7b'}]}).encode('utf-8'))
    mocker.patch.object(
        recoveryModule.urllib.request, 'urlopen',
        return_value=tagsResponse)
    submitSpy = mocker.patch.object(recoveryModule, 'submit', return_value=1)
    mocker.patch.object(
        recoveryModule, 'poll',
        return_value={'direction': 'east', 'reason': 'clear path'})
    context = _context()
    context['ng_screenshot'] = np.zeros((20, 20), dtype=np.uint8)

    for seconds in (0, 21, 41, 61, 91, 92):
        _advance(mocker, context, seconds)

    submitSpy.assert_called_once()
    assert recoveryModule._LLM_STATE['baseUrl'] == 'http://localhost:11434'
    pressSpy.assert_called_with('right')
    assert logSpy.call_args[0][0] == 'recoveryLlmHint'


def test_auto_probe_stays_off_without_model(mocker, monkeypatch):
    monkeypatch.delenv('TIBIAPILOT_LLM_URL', raising=False)
    tagsResponse = _FakeResponse(json.dumps({'models': []}).encode('utf-8'))
    mocker.patch.object(
        recoveryModule.urllib.request, 'urlopen',
        return_value=tagsResponse)

    baseUrl, model = recoveryModule._llmEndpoint()

    assert baseUrl is None
