"""Unit suite for common/task.py — the n8n <-> pool-worker task/result atom
contract over Redis.

Pure unit: no live Redis. A FakeRedis in-memory KV (decode_responses semantics)
stands in for the client; we assert on the JSON shape written, the exact key
names, and the TTL (ex=) that rides each set(). The Redis-construction helper
get_redis() is checked by monkeypatching redis.from_url so no socket is opened.
"""
import json
import os
import sys

import pytest

import task
from _commonfakes import FakeRedis


# ── Pool ID registry ────────────────────────────────────────────────────────

def test_pool_id_registry_values():
    # Single source of truth for pool ids — the docstring table must match.
    assert task.POOL_TOKENIZER == 1, "tokenizer pool id"
    assert task.POOL_FORWARD_PASS == 2, "forward_pass pool id"
    assert task.POOL_CHUNKER == 3, "chunker pool id"
    assert task.POOL_EXTRACTOR == 4, "extractor pool id"
    assert task.POOL_TAGGER == 5, "tagger pool id"
    assert task.POOL_STORAGE == 6, "storage pool id"
    assert task.POOL_COMPRESSOR == 7, "compressor pool id"


def test_pool_ids_are_unique_and_contiguous():
    ids = [
        task.POOL_TOKENIZER, task.POOL_FORWARD_PASS, task.POOL_CHUNKER,
        task.POOL_EXTRACTOR, task.POOL_TAGGER, task.POOL_STORAGE,
        task.POOL_COMPRESSOR,
    ]
    assert ids == [1, 2, 3, 4, 5, 6, 7], "pool ids must be 1..7 contiguous"
    assert len(set(ids)) == len(ids), "pool ids must be unique"


# ── read_task ───────────────────────────────────────────────────────────────

def test_read_task_returns_parsed_dict():
    payload = {"doc_id": "d1", "stage": "raw", "storage_key": "k",
               "context_uuid": "z", "metadata": "{}"}
    r = FakeRedis({"task:abc": json.dumps(payload)})
    got = task.read_task(r, "abc")
    assert got == payload, "read_task must JSON-decode the stored atom"


def test_read_task_uses_correct_key_prefix():
    r = FakeRedis({"task:the-uuid": json.dumps({"x": 1})})
    assert task.read_task(r, "the-uuid") == {"x": 1}
    # A different uuid must not collide.
    assert task.read_task(r, "other") is None


def test_read_task_missing_key_returns_none():
    r = FakeRedis()
    assert task.read_task(r, "nope") is None, "missing key -> None, not raise"


def test_read_task_empty_string_value_returns_none():
    # `if not raw` treats an empty string (falsey) as absent.
    r = FakeRedis({"task:e": ""})
    assert task.read_task(r, "e") is None


def test_read_task_invalid_json_raises():
    r = FakeRedis({"task:bad": "{not json"})
    with pytest.raises(json.JSONDecodeError):
        task.read_task(r, "bad")


# ── parse_task ──────────────────────────────────────────────────────────────

def test_parse_task_unpacks_all_fields_and_decodes_metadata():
    meta = {"title": "T", "author": "A", "year": 2020}
    t = {
        "doc_id": "doc-1",
        "stage": "tokenized",
        "storage_key": "pipeline/z/doc-1/stage.tokenized.json",
        "context_uuid": "ctx-9",
        "metadata": json.dumps(meta),
    }
    doc_id, stage, key, ctx, parsed_meta = task.parse_task(t)
    assert doc_id == "doc-1"
    assert stage == "tokenized"
    assert key == "pipeline/z/doc-1/stage.tokenized.json"
    assert ctx == "ctx-9"
    assert parsed_meta == meta, "metadata must come back as a dict, not a str"
    assert isinstance(parsed_meta, dict)


def test_parse_task_missing_metadata_defaults_to_empty_dict():
    t = {"doc_id": "d", "stage": "raw", "storage_key": "k", "context_uuid": "c"}
    _, _, _, _, meta = task.parse_task(t)
    assert meta == {}, "absent metadata key -> {} via .get default"


def test_parse_task_returns_five_tuple_in_documented_order():
    t = {"doc_id": "1", "stage": "2", "storage_key": "3",
         "context_uuid": "4", "metadata": "{}"}
    result = task.parse_task(t)
    assert isinstance(result, tuple)
    assert len(result) == 5
    assert result[:4] == ("1", "2", "3", "4")


def test_parse_task_missing_required_field_raises_keyerror():
    # doc_id is read with [] not .get — a malformed atom surfaces as KeyError.
    t = {"stage": "raw", "storage_key": "k", "context_uuid": "c", "metadata": "{}"}
    with pytest.raises(KeyError):
        task.parse_task(t)


def test_parse_task_invalid_metadata_json_raises():
    t = {"doc_id": "d", "stage": "s", "storage_key": "k",
         "context_uuid": "c", "metadata": "{bad"}
    with pytest.raises(json.JSONDecodeError):
        task.parse_task(t)


def test_parse_task_nested_metadata_roundtrips():
    meta = {"tags": ["a", "b"], "nested": {"k": [1, 2, 3]}, "n": None}
    t = {"doc_id": "d", "stage": "s", "storage_key": "k",
         "context_uuid": "c", "metadata": json.dumps(meta)}
    _, _, _, _, parsed = task.parse_task(t)
    assert parsed == meta


# ── read_task + parse_task composed (the real worker path) ───────────────────

def test_read_then_parse_roundtrip():
    meta = {"title": "Roundtrip"}
    task.write_task  # sanity: symbol exists
    r = FakeRedis()
    task.write_task(r, "u1", "doc", "raw", "skey", "ctx", meta, ttl=10)
    atom = task.read_task(r, "u1")
    assert atom is not None
    doc_id, stage, key, ctx, parsed = task.parse_task(atom)
    assert (doc_id, stage, key, ctx) == ("doc", "raw", "skey", "ctx")
    assert parsed == meta


# ── write_result ────────────────────────────────────────────────────────────

def test_write_result_ok_default_shape_and_key_and_ttl():
    r = FakeRedis()
    task.write_result(r, "u1", "tokenized", "out/key")
    assert "task:u1:result" in r.store, "result atom key prefix is :result"
    body = json.loads(r.store["task:u1:result"])
    assert body == {
        "out_stage": "tokenized",
        "out_key": "out/key",
        "status": "ok",
        "error": None,
        "token_count": None,
    }, "default result atom shape"
    assert r.ttl["task:u1:result"] == task.RESULT_TTL, "result atom must carry RESULT_TTL"


def test_write_result_error_status_and_message():
    r = FakeRedis()
    task.write_result(r, "u2", "hidden", "", status="error",
                      error="forward pass OOM")
    body = json.loads(r.store["task:u2:result"])
    assert body["status"] == "error"
    assert body["error"] == "forward pass OOM"
    assert body["out_key"] == ""


def test_write_result_token_count_rides_done_signal():
    r = FakeRedis()
    task.write_result(r, "u3", "tokenized", "k", token_count=4096)
    body = json.loads(r.store["task:u3:result"])
    assert body["token_count"] == 4096, "token_count carried for size-routing"


def test_write_result_token_count_zero_is_preserved_not_dropped():
    r = FakeRedis()
    task.write_result(r, "u4", "tokenized", "k", token_count=0)
    body = json.loads(r.store["task:u4:result"])
    # 0 is a legitimate count; must serialize as 0, not be coerced to null.
    assert body["token_count"] == 0


def test_write_result_uses_result_key_not_task_key():
    r = FakeRedis()
    task.write_result(r, "uX", "s", "k")
    assert "task:uX" not in r.store, "must not clobber the input task atom"
    assert "task:uX:result" in r.store


# ── write_task ──────────────────────────────────────────────────────────────

def test_write_task_shape_and_metadata_is_reencoded_string():
    r = FakeRedis()
    meta = {"title": "X", "year": 1999}
    task.write_task(r, "u1", "doc-1", "raw", "skey", "ctx-1", meta)
    body = json.loads(r.store["task:u1"])
    assert body["doc_id"] == "doc-1"
    assert body["stage"] == "raw"
    assert body["storage_key"] == "skey"
    assert body["context_uuid"] == "ctx-1"
    # metadata is stored as a JSON *string*, not a nested object.
    assert isinstance(body["metadata"], str), "metadata must be a JSON-encoded str"
    assert json.loads(body["metadata"]) == meta


def test_write_task_default_ttl_is_one_day():
    r = FakeRedis()
    task.write_task(r, "u1", "d", "raw", "k", "c", {})
    assert r.ttl["task:u1"] == 86400, "default task TTL is 24h"


def test_write_task_explicit_ttl_passthrough():
    r = FakeRedis()
    task.write_task(r, "u1", "d", "raw", "k", "c", {}, ttl=120)
    assert r.ttl["task:u1"] == 120, "explicit ttl must override the default"


def test_write_task_uses_bare_task_key_no_result_suffix():
    r = FakeRedis()
    task.write_task(r, "abc", "d", "raw", "k", "c", {})
    assert "task:abc" in r.store
    assert "task:abc:result" not in r.store


def test_write_task_empty_metadata_serializes_as_empty_object_string():
    r = FakeRedis()
    task.write_task(r, "u", "d", "raw", "k", "c", {})
    body = json.loads(r.store["task:u"])
    assert body["metadata"] == "{}"


# ── delete_task ─────────────────────────────────────────────────────────────

def test_delete_task_removes_both_atoms_in_one_call():
    r = FakeRedis({
        "task:u1": "x",
        "task:u1:result": "y",
        "task:other": "keep",
    })
    task.delete_task(r, "u1")
    assert "task:u1" not in r.store, "input atom deleted"
    assert "task:u1:result" not in r.store, "result atom deleted"
    assert "task:other" in r.store, "unrelated key untouched"


def test_delete_task_passes_both_keys_to_single_delete():
    r = FakeRedis({"task:u1": "x", "task:u1:result": "y"})
    task.delete_task(r, "u1")
    # Variadic delete — both keys handed to one delete() call (order preserved).
    assert r.deleted == ["task:u1", "task:u1:result"]


def test_delete_task_is_idempotent_on_missing_keys():
    r = FakeRedis()
    task.delete_task(r, "ghost")  # must not raise even if nothing exists
    assert r.deleted == ["task:ghost", "task:ghost:result"]


# ── get_redis ───────────────────────────────────────────────────────────────

def test_get_redis_uses_module_url_and_decode_responses(monkeypatch):
    captured = {}

    def fake_from_url(url, **kwargs):
        captured["url"] = url
        captured.update(kwargs)
        return "SENTINEL_CLIENT"

    monkeypatch.setattr(task._redis_mod, "from_url", fake_from_url)
    client = task.get_redis()
    assert client == "SENTINEL_CLIENT"
    assert captured["url"] == task.REDIS_URL
    assert captured["decode_responses"] is True, "must decode to str for json.loads"
    assert captured["socket_connect_timeout"] == 5
    assert captured["socket_timeout"] == 60
    assert captured["retry_on_timeout"] is True


def test_get_redis_does_not_ping_or_open_socket(monkeypatch):
    # get_redis is a pure constructor — it must not eagerly ping (that belongs
    # to the caller). A from_url that records call count proves no extra I/O.
    calls = {"n": 0}

    def fake_from_url(url, **kwargs):
        calls["n"] += 1
        return object()

    monkeypatch.setattr(task._redis_mod, "from_url", fake_from_url)
    task.get_redis()
    assert calls["n"] == 1


# ── env-driven defaults (REDIS_URL / RESULT_TTL) ─────────────────────────────

def test_redis_url_default_constant_present():
    # Module imported without REDIS_URL env -> documented default.
    # (We can't easily re-import to flip env here; assert the value is a
    #  redis:// URL string so a typo in the default is caught.)
    assert isinstance(task.REDIS_URL, str)
    assert task.REDIS_URL.startswith("redis://"), "REDIS_URL must be a redis:// URL"


def test_result_ttl_is_int():
    assert isinstance(task.RESULT_TTL, int)
    assert task.RESULT_TTL > 0


def test_result_ttl_honours_env_on_fresh_import(monkeypatch):
    # Re-import task with TASK_RESULT_TTL set proves the int() coercion path.
    import importlib
    monkeypatch.setenv("TASK_RESULT_TTL", "777")
    fresh = importlib.reload(task)
    try:
        assert fresh.RESULT_TTL == 777, "RESULT_TTL must read TASK_RESULT_TTL env"
        assert isinstance(fresh.RESULT_TTL, int)
    finally:
        # Restore the module to env-default state so later tests see the original.
        monkeypatch.delenv("TASK_RESULT_TTL", raising=False)
        importlib.reload(task)


def test_redis_url_honours_env_on_fresh_import(monkeypatch):
    import importlib
    monkeypatch.setenv("REDIS_URL", "redis://10.0.0.9:6380/2")
    fresh = importlib.reload(task)
    try:
        assert fresh.REDIS_URL == "redis://10.0.0.9:6380/2"
    finally:
        monkeypatch.delenv("REDIS_URL", raising=False)
        importlib.reload(task)
