"""
Tests for hardening fixes (v3.4 / v3.5):
- Fix 1: one-shot cache (skip extract if summary already exists)
- Fix 2: deterministic truncation (cut at \n boundary)
- Fix 3: numeric normalization in _parse_number (NBSP, comma separators, units)
- Fix D: active_doc_id set immediately on upload (doc_ctx field + run.py priority)
- Fix E: no "немає даних" when file_id is present but summary is empty
"""
import sys
import os
import io
import asyncio

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "gateway-bot"))


# ── Fix 2: deterministic truncation ─────────────────────────────────────────

def test_truncate_by_line_short_text():
    """Текст коротший за ліміт — повертається без змін."""
    from services.doc_service import _truncate_by_line
    text = "Рядок 1\nРядок 2\nРядок 3"
    assert _truncate_by_line(text, max_chars=500) == text


def test_truncate_by_line_cuts_at_newline():
    """Обрізка відбувається по межі рядка, не посередині."""
    from services.doc_service import _truncate_by_line
    # Якщо max_chars=20 і текст "AAAAAAAAAAA\nBBBBBBBBBBB"
    # Обрізаємо на \n, не посередині BBBBB
    text = "AAAAAAAAAAA\nBBBBBBBBBBB\nCCCCCCCCCCC"
    result = _truncate_by_line(text, max_chars=20)
    # Результат має закінчуватись після першого рядка
    assert not result.endswith("B"), f"Should not cut mid-line, got: {result!r}"
    assert "AAAAAAAAAAA" in result
    assert "\n" not in result.strip() or result.endswith("AAAAAAAAAAA")


def test_truncate_by_line_does_not_end_with_partial_row():
    """Табличний рядок не обривається посередині."""
    from services.doc_service import _truncate_by_line
    rows = "\n".join([f"Показник_{i}\t{i * 1000}" for i in range(100)])
    result = _truncate_by_line(rows, max_chars=200)
    # Останній рядок має бути повним (містити \t або бути єдиним полем)
    last_line = result.split("\n")[-1]
    # Не перевіряємо повноту — просто що немає обривання до першого символу
    assert len(last_line) > 0


def test_truncate_by_line_exact_boundary():
    """Якщо ліміт рівно на \n — не додає зайвого."""
    from services.doc_service import _truncate_by_line
    text = "12345\n67890\nABCDE"
    result = _truncate_by_line(text, max_chars=6)
    # rfind("\n", 0, 6) знайде \n на позиції 5 → обріже до "12345"
    assert result == "12345"


def test_extract_summary_from_bytes_truncates_at_line():
    """extract_summary_from_bytes для великого XLSX не рве рядок посередині."""
    import openpyxl
    wb = openpyxl.Workbook()
    ws = wb.active
    ws.title = "Data"
    for i in range(400):
        ws.append([f"Показник_{i}", i * 100, "грн"])
    buf = io.BytesIO()
    wb.save(buf)
    xlsx_bytes = buf.getvalue()

    from services.doc_service import extract_summary_from_bytes
    result = extract_summary_from_bytes("big.xlsx", xlsx_bytes)
    # Результат не повинен закінчуватись посередині рядка таблиці
    if result:
        last_char = result[-1]
        # Останній символ має бути частиною завершеного рядка (не обривом)
        assert last_char not in ("_",), f"Looks like mid-line cut: ...{result[-30:]!r}"


# ── Fix 3: numeric normalization ─────────────────────────────────────────────

def test_parse_number_plain():
    """Звичайне число."""
    from crews.agromatrix_crew.doc_facts import _parse_number
    assert _parse_number("5972016") == 5972016.0


def test_parse_number_space_separator():
    """Число з пробілами як роздільниками тисяч."""
    from crews.agromatrix_crew.doc_facts import _parse_number
    assert _parse_number("5 972 016") == 5972016.0


def test_parse_number_nbsp_separator():
    """Число з NBSP (U+00A0) як роздільником тисяч — openpyxl так форматує."""
    from crews.agromatrix_crew.doc_facts import _parse_number
    assert _parse_number("5\u00a0972\u00a0016") == 5972016.0


def test_parse_number_thin_space():
    """Число з тонким пробілом (U+202F)."""
    from crews.agromatrix_crew.doc_facts import _parse_number
    assert _parse_number("5\u202f972\u202f016") == 5972016.0


def test_parse_number_comma_thousands():
    """Кома як роздільник тисяч: 5,972,016."""
    from crews.agromatrix_crew.doc_facts import _parse_number
    assert _parse_number("5,972,016") == 5972016.0


def test_parse_number_comma_decimal():
    """Кома як десяткова: 12,5 → 12.5."""
    from crews.agromatrix_crew.doc_facts import _parse_number
    assert _parse_number("12,5") == 12.5


def test_parse_number_with_unit_uah():
    """Число з одиницею грн."""
    from crews.agromatrix_crew.doc_facts import _parse_number
    assert _parse_number("1521084 грн") == 1521084.0


def test_parse_number_with_unit_ha():
    """Число з одиницею га."""
    from crews.agromatrix_crew.doc_facts import _parse_number
    assert _parse_number("497 га") == 497.0


def test_parse_number_with_unit_uah_per_ha():
    """Число з одиницею грн/га."""
    from crews.agromatrix_crew.doc_facts import _parse_number
    assert _parse_number("12015 грн/га") == 12015.0


def test_parse_number_negative_brackets():
    """Від'ємне число в дужках (бухгалтерський стиль): (1 234) → -1234."""
    from crews.agromatrix_crew.doc_facts import _parse_number
    result = _parse_number("(1 234)")
    assert result == -1234.0


def test_parse_number_float_with_dot():
    """Число з крапкою як десятковою."""
    from crews.agromatrix_crew.doc_facts import _parse_number
    assert _parse_number("12016.13") == 12016.13


def test_parse_number_zero_string():
    """Порожній рядок → 0.0."""
    from crews.agromatrix_crew.doc_facts import _parse_number
    assert _parse_number("") == 0.0


def test_parse_number_invalid():
    """Нечислові символи → 0.0."""
    from crews.agromatrix_crew.doc_facts import _parse_number
    assert _parse_number("n/a") == 0.0


def test_extract_doc_facts_from_xlsx_tabular_text():
    """extract_doc_facts витягує факти з XLSX-табличного тексту."""
    from crews.agromatrix_crew.doc_facts import extract_doc_facts
    # Типовий вивід з extract_summary_from_bytes для XLSX
    text = """=== Аркуш: Кукурудза 2024 ===
Показник\tЗначення\tОд.вим
Площа\t497\tга
Прибуток\t5\u00a0972\u00a0016\tгрн
Витрати на добрива\t1\u00a0521\u00a0084\tгрн
Прибуток/га\t12\u00a0015\tгрн/га"""
    facts = extract_doc_facts(text)
    assert facts.get("area_ha") == 497.0, f"area_ha not found: {facts}"
    # profit_uah або інший ключ має містити ~5972016
    profit = facts.get("profit_uah") or facts.get("revenue_uah")
    assert profit and abs(profit - 5972016) < 1, f"profit not found correctly: {facts}"


def test_extract_doc_facts_nbsp_numbers():
    """extract_doc_facts розпізнає числа з NBSP."""
    from crews.agromatrix_crew.doc_facts import extract_doc_facts
    text = "Прибуток: 5\u00a0972\u00a0016 грн. Площа: 497 га."
    facts = extract_doc_facts(text)
    assert facts.get("area_ha") == 497.0
    profit = facts.get("profit_uah") or facts.get("revenue_uah")
    assert profit and abs(profit - 5972016) < 1, f"facts: {facts}"


# ── Fix 1: one-shot cache ────────────────────────────────────────────────────

async def _scenario_one_shot_cache():
    """
    Симулює: upload → extract → повторний upload того самого файлу.
    Другий раз: fetch_telegram_file_bytes НЕ має викликатись.
    """
    from unittest.mock import AsyncMock, patch, MagicMock

    stored = {}
    fetch_calls = []

    async def mock_upsert(user_id, fact_key, fact_value_json, team_id=None, **kwargs):
        stored[fact_key] = fact_value_json
        return True

    async def mock_get(user_id, fact_key, **kwargs):
        val = stored.get(fact_key)
        return {"fact_value_json": val} if val else None

    mock_mem = AsyncMock()
    mock_mem.upsert_fact = AsyncMock(side_effect=mock_upsert)
    mock_mem.get_fact = AsyncMock(side_effect=mock_get)

    import openpyxl
    wb = openpyxl.Workbook()
    ws = wb.active
    ws.title = "Test"
    ws.append(["Показник", "Значення"])
    ws.append(["Прибуток", 5972016])
    buf = io.BytesIO()
    wb.save(buf)
    xlsx_bytes = buf.getvalue()

    async def mock_fetch(bot_token, file_id):
        fetch_calls.append(file_id)
        return xlsx_bytes

    with patch("services.doc_service.memory_client", mock_mem):
        from services.doc_service import (
            save_chat_doc_context,
            upsert_chat_doc_context_with_summary,
            extract_summary_from_bytes,
            get_chat_doc_context,
        )
        doc_ctx = {"doc_id": "fuid_abc", "file_unique_id": "fuid_abc",
                   "file_id": "file123", "file_name": "test.xlsx", "source": "telegram"}

        # Перший upload: save базовий + extract + upsert summary
        await save_chat_doc_context("chat_oneshot", "agromatrix", doc_ctx)
        summary = extract_summary_from_bytes("test.xlsx", xlsx_bytes)
        await upsert_chat_doc_context_with_summary("chat_oneshot", "agromatrix", doc_ctx, summary)

        # Симулюємо логіку Fix 1: перевіряємо чи є вже summary
        existing = await get_chat_doc_context("chat_oneshot", "agromatrix")
        already_have = (
            existing
            and existing.get("extracted_summary")
            and (existing.get("file_unique_id") or existing.get("doc_id")) == "fuid_abc"
        )

    return already_have, fetch_calls


def test_one_shot_cache_detects_existing_summary():
    """Fix 1: після першого extract — логіка визначає 'already_have=True' для того самого файлу."""
    already_have, fetch_calls = asyncio.run(_scenario_one_shot_cache())
    assert already_have is True, "Should detect existing summary for same file_unique_id"


def test_one_shot_cache_different_fuid_not_skipped():
    """Fix 1: різний file_unique_id — не вважається закешованим."""
    async def _run():
        from unittest.mock import AsyncMock, patch
        stored = {}

        async def mock_upsert(user_id, fact_key, fact_value_json, team_id=None, **kwargs):
            stored[fact_key] = fact_value_json
            return True

        async def mock_get(user_id, fact_key, **kwargs):
            val = stored.get(fact_key)
            return {"fact_value_json": val} if val else None

        mock_mem = AsyncMock()
        mock_mem.upsert_fact = AsyncMock(side_effect=mock_upsert)
        mock_mem.get_fact = AsyncMock(side_effect=mock_get)

        with patch("services.doc_service.memory_client", mock_mem):
            from services.doc_service import (
                save_chat_doc_context, upsert_chat_doc_context_with_summary,
                extract_summary_from_bytes, get_chat_doc_context,
            )
            ctx_a = {"doc_id": "fuid_A", "file_unique_id": "fuid_A",
                     "file_name": "a.xlsx", "source": "telegram"}

            import openpyxl
            wb = openpyxl.Workbook()
            ws = wb.active
            ws.append(["Data", 123])
            buf = io.BytesIO()
            wb.save(buf)
            xlsx_bytes = buf.getvalue()

            await save_chat_doc_context("chat_diff", "agromatrix", ctx_a)
            summary = extract_summary_from_bytes("a.xlsx", xlsx_bytes)
            await upsert_chat_doc_context_with_summary("chat_diff", "agromatrix", ctx_a, summary)

            # Перевіряємо для ІНШОГО fuid
            existing = await get_chat_doc_context("chat_diff", "agromatrix")
            already_have = (
                existing
                and existing.get("extracted_summary")
                and (existing.get("file_unique_id") or existing.get("doc_id")) == "fuid_B"  # інший!
            )
        return already_have

    result = asyncio.run(_run())
    assert result is False, "Different file_unique_id should NOT be considered cached"


# ── Fix D: active_doc_id set immediately on upload ───────────────────────────

def test_fix_d_active_doc_id_in_doc_ctx():
    """Fix D: doc_ctx_to_save містить active_doc_id = file_unique_id одразу."""
    # Симулюємо те, що робить handle_stepan_message при upload
    _fu_id = "test_fuid_xyz"
    _file_id_tg = "file_id_abc"
    _fname = "Звіт.xlsx"

    doc_ctx_to_save = {
        "doc_id": _fu_id,
        "file_unique_id": _fu_id,
        "file_id": _file_id_tg,
        "file_name": _fname,
        "source": "telegram",
        "active_doc_id": _fu_id,  # Fix D: явно фіксується
    }
    assert doc_ctx_to_save["active_doc_id"] == _fu_id, "active_doc_id must equal file_unique_id"
    assert doc_ctx_to_save["active_doc_id"] == doc_ctx_to_save["file_unique_id"]


def test_fix_d_run_py_prioritizes_active_doc_id():
    """Fix D: run.py читає active_doc_id як пріоритет (перед doc_id і file_unique_id)."""
    # Симулюємо логіку з run.py
    def compute_current_doc_id(doc_context: dict) -> str | None:
        if not doc_context:
            return None
        return (
            doc_context.get("active_doc_id")
            or doc_context.get("doc_id")
            or doc_context.get("file_unique_id")
            or None
        )

    # Кейс: active_doc_id є — має бути пріоритетом
    ctx_with_anchor = {"active_doc_id": "anchor_id", "doc_id": "doc_id_old", "file_unique_id": "fuid_old"}
    assert compute_current_doc_id(ctx_with_anchor) == "anchor_id"

    # Кейс: active_doc_id відсутній — fallback на doc_id
    ctx_no_anchor = {"doc_id": "doc_id_ok", "file_unique_id": "fuid_ok"}
    assert compute_current_doc_id(ctx_no_anchor) == "doc_id_ok"

    # Кейс: нічого немає
    assert compute_current_doc_id({}) is None
    assert compute_current_doc_id(None) is None


# ── Fix E: no "немає даних" when file_id present ─────────────────────────────

def _build_doc_snippet_logic(doc_context: dict) -> str:
    """Спрощена копія логіки DOC BRIDGE з run.py для тестів."""
    if not doc_context:
        return ""
    parts = []
    doc_title = doc_context.get("title") or doc_context.get("file_name") or ""
    doc_summary = doc_context.get("extracted_summary") or doc_context.get("summary", "")

    if doc_title:
        parts.append(f"=== ДОКУМЕНТ: «{doc_title}» ===")

    if doc_summary:
        parts.append(f"ЗМІСТ ДОКУМЕНТА:\n{doc_summary[:3000]}")
        parts.append("=== КІНЕЦЬ ДОКУМЕНТА ===")
    elif doc_title:
        # Fix E logic
        _doc_file_id = doc_context.get("file_id") or doc_context.get("file_unique_id") or ""
        if _doc_file_id:
            parts.append(
                f"(Вміст «{doc_title}» ще не витягнутий у цій сесії — "
                f"але файл є. Перевір ІСТОРІЮ ДІАЛОГУ..."
            )
        else:
            parts.append(
                f"(Вміст «{doc_title}» недоступний. Якщо в history нічого — попроси надіслати файл ще раз.)"
            )
    return "\n".join(parts)


def _build_instruction(doc_summary_snippet: str, chat_history: str, doc_context: dict) -> str:
    """Спрощена копія логіки інструкцій з run.py для тестів (Fix E)."""
    if doc_summary_snippet and "ЗМІСТ ДОКУМЕНТА" in doc_summary_snippet:
        return "ІНСТРУКЦІЯ: У тебе є вміст документа вище."
    elif chat_history:
        return "ІНСТРУКЦІЯ: Використовуй ІСТОРІЮ ДІАЛОГУ"
    elif doc_context and (doc_context.get("file_id") or doc_context.get("file_unique_id")):
        return "ІНСТРУКЦІЯ: Файл отримано, але вміст ще не витягнутий. НЕ кажи 'немає даних'"
    else:
        return "Дай коротку, конкретну відповідь."


def test_fix_e_no_summary_but_file_id_present():
    """Fix E: якщо summary порожній але file_id є — НЕ просити 'надіслати ще раз'."""
    ctx = {"file_name": "Звіт.xlsx", "file_id": "file123", "file_unique_id": "fuid456"}
    snippet = _build_doc_snippet_logic(ctx)
    # Snippet має містити "файл є", але НЕ "надішли ще раз"
    assert "надіслати файл ще раз" not in snippet, f"Should not ask to resend when file_id exists: {snippet!r}"
    assert "файл є" in snippet or "Вміст" in snippet


def test_fix_e_no_summary_no_file_id_allows_resend():
    """Fix E: якщо summary порожній і file_id немає — можна попросити надіслати."""
    ctx = {"file_name": "Звіт.xlsx"}  # без file_id
    snippet = _build_doc_snippet_logic(ctx)
    assert "надіслати файл ще раз" in snippet, f"Should suggest resend when no file_id: {snippet!r}"


def test_fix_e_instruction_with_file_id_no_summary():
    """Fix E: інструкція забороняє агенту казати 'немає даних' коли file_id присутній."""
    ctx = {"file_name": "test.xlsx", "file_id": "fid123"}
    instruction = _build_instruction("", "", ctx)
    # Інструкція має ЗАБОРОНЯТИ "немає даних" (містити заперечення "НЕ кажи")
    assert "не кажи" in instruction.lower(), f"Instruction must forbid 'немає даних': {instruction!r}"
    # І не просити надіслати файл ще раз
    assert "надіслати" not in instruction.lower()


def test_fix_e_instruction_with_summary_normal():
    """Fix E: якщо summary є — стандартна інструкція без спеціального тексту."""
    ctx = {"file_name": "test.xlsx", "file_id": "fid123", "extracted_summary": "Прибуток: 5 млн грн"}
    snippet = _build_doc_snippet_logic(ctx)
    instruction = _build_instruction(snippet, "", ctx)
    assert "ЗМІСТ ДОКУМЕНТА" in snippet
    assert "У тебе є вміст документа" in instruction


def test_fix_e_no_doc_context_fallback_generic():
    """Fix E: без doc_context — стандартна відповідь без згадки документа."""
    instruction = _build_instruction("", "", {})
    assert "Дай коротку" in instruction
