"""Tests for spens.viewer (trace parsing and session data loading)."""

import json
from pathlib import Path

from spens.viewer import (
    _assemble_anthropic_chunks,
    _assemble_openai_chunks,
    _assemble_openai_responses_chunks,
    _compute_display_messages,
    _find_sessions_dir,
    _first_user_text,
    _generate_diff,
    _load_rollback,
    _msg_text,
    _safe_json,
    _summarize_message,
    _summarize_responses_input,
    list_sessions,
    parse_traces,
)

# -- _find_sessions_dir (custom --spens-dir support) ------------------------


def test_find_sessions_dir_custom_spens_dir_wins(tmp_path: Path) -> None:
    """An explicit spens_dir is used directly, even if a .spens/sessions
    directory also exists near the start path."""
    custom = tmp_path / "state"
    (custom / "sessions").mkdir(parents=True)
    start = tmp_path / "ws"
    (start / ".spens" / "sessions").mkdir(parents=True)
    assert _find_sessions_dir(start, spens_dir=custom) == custom.resolve() / "sessions"


def test_find_sessions_dir_custom_spens_dir_need_not_exist(tmp_path: Path) -> None:
    """A custom dir is returned as-is (start_viewer reports the missing
    directory); the existence check is left to the caller."""
    custom = tmp_path / "not-created-yet"
    assert _find_sessions_dir(tmp_path, spens_dir=custom) == custom.resolve() / "sessions"


def test_find_sessions_dir_walks_up_from_start(tmp_path: Path) -> None:
    sessions = tmp_path / ".spens" / "sessions"
    sessions.mkdir(parents=True)
    sub = tmp_path / "sub" / "deeper"
    sub.mkdir(parents=True)
    assert _find_sessions_dir(sub) == sessions


def test_find_sessions_dir_falls_back_to_start(tmp_path: Path) -> None:
    """No .spens anywhere above: fall back to <start>/.spens/sessions."""
    start = tmp_path / "ws"
    start.mkdir()
    assert _find_sessions_dir(start) == start / ".spens" / "sessions"


def test_dict_and_list_passthrough() -> None:
    d = {"a": 1}
    l = [1, 2]
    assert _safe_json(d) is d
    assert _safe_json(l) is l


def test_valid_json_string() -> None:
    assert _safe_json('{"a": 1}') == {"a": 1}


def test_invalid_json_string() -> None:
    assert _safe_json("not json") == {"_raw": "not json"}


def test_non_string_returns_as_is() -> None:
    assert _safe_json(None) is None
    assert _safe_json(42) == 42


def test_string_content() -> None:
    result = _summarize_message({"role": "user", "content": "hello"})
    assert result["role"] == "user"
    assert result["parts"] == [{"type": "text", "text": "hello"}]


def test_anthropic_blocks() -> None:
    msg = {
        "role": "assistant",
        "content": [
            {"type": "text", "text": "answer"},
            {"type": "thinking", "thinking": "hmm"},
            {"type": "tool_use", "id": "toolu_1", "name": "read_file", "input": {"path": "x"}},
            {"type": "tool_result", "tool_use_id": "toolu_1", "content": "content"},
        ],
    }
    parts = _summarize_message(msg)["parts"]
    assert parts[0] == {"type": "text", "text": "answer"}
    assert parts[1] == {"type": "thinking", "text": "hmm"}
    assert parts[2] == {"type": "tool_use", "id": "toolu_1", "name": "read_file", "input": {"path": "x"}}
    assert parts[3] == {"type": "tool_result", "tool_use_id": "toolu_1", "content": "content", "is_error": False}


def test_openai_tool_calls() -> None:
    msg = {
        "role": "assistant",
        "content": None,
        "tool_calls": [{"id": "call_1", "function": {"name": "read_file", "arguments": '{"path": "x"}'}}],
    }
    parts = _summarize_message(msg)["parts"]
    assert parts[0]["type"] == "tool_use"
    assert parts[0]["name"] == "read_file"
    assert parts[0]["input"] == {"path": "x"}


def test_openai_tool_result() -> None:
    result = _summarize_message({"role": "tool", "tool_call_id": "call_1", "content": None})
    assert result["parts"][0]["type"] == "tool_result"
    assert result["parts"][0]["tool_use_id"] == "call_1"


def test_text_joined() -> None:
    msg = {"role": "assistant", "parts": [
        {"type": "text", "text": "one"},
        {"type": "thinking", "text": "secret"},
        {"type": "tool_use", "name": "bash"},
        {"type": "tool_result"},
    ]}
    assert _msg_text(msg) == "one\n[thinking]\n[tool:bash]\n[tool_result]"


def test_first_user_text() -> None:
    messages = [
        {"role": "system", "parts": [{"type": "text", "text": "sys"}]},
        {"role": "user", "parts": [{"type": "text", "text": "do the thing"}]},
        {"role": "assistant", "parts": [{"type": "text", "text": "ok"}]},
    ]
    assert _first_user_text(messages) == "do the thing"


def test_first_user_text_empty() -> None:
    assert _first_user_text([]) == ""


def test_chain_delta() -> None:
    calls = [
        {"messages": [
            {"role": "user", "content": "task"},
            {"role": "assistant", "content": "a"},
        ]},
        {"messages": [
            {"role": "user", "content": "task"},
            {"role": "assistant", "content": "a"},
            {"role": "user", "content": "more"},
        ]},
        {"messages": [
            {"role": "user", "content": "new task"},
            {"role": "assistant", "content": "b"},
        ]},
    ]
    _compute_display_messages(calls)
    assert calls[0]["chain_start"]
    assert len(calls[0]["display_messages"]) == 2
    assert not calls[1]["chain_start"]
    assert len(calls[1]["display_messages"]) == 1
    assert calls[2]["chain_start"]


def test_openai_chunks() -> None:
    chunks = [
        {"content": {"model": "gpt-4o", "choices": [{"delta": {"content": "Hello"}}]}},
        {"content": {"choices": [{"delta": {"reasoning_content": "hmm"}}]}},
        {"content": {"choices": [{"delta": {"tool_calls": [{"index": 0, "id": "call_1", "function": {"name": "read_file", "arguments": '{"path": "'}}]}}]}},
        {"content": {"choices": [{"delta": {"tool_calls": [{"index": 0, "function": {"arguments": 'x"}'}}]}}]}},
        {"content": {"choices": [{"delta": {}}, {"finish_reason": "stop"}], "usage": {"total_tokens": 10}}},
    ]
    result = _assemble_openai_chunks(chunks)
    assert result["content"] == "Hello"
    assert result["reasoning"] == "hmm"
    assert result["finish_reason"] == "stop"
    assert result["model"] == "gpt-4o"
    assert result["usage"] == {"total_tokens": 10}
    assert result["tool_calls"] == [{"id": "call_1", "name": "read_file", "arguments": '{"path": "x"}'}]


def test_anthropic_chunks() -> None:
    chunks = [
        {"content": {"type": "message_start", "message": {"model": "claude-3", "usage": {"input_tokens": 10}}}},
        {"content": {"type": "content_block_start", "index": 0, "content_block": {"type": "text"}}},
        {"content": {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hi"}}},
        {"content": {"type": "content_block_start", "index": 1, "content_block": {"type": "tool_use", "name": "read_file", "id": "toolu_1"}}},
        {"content": {"type": "content_block_delta", "index": 1, "delta": {"type": "input_json_delta", "partial_json": '{"path":'}}},
        {"content": {"type": "content_block_delta", "index": 1, "delta": {"type": "input_json_delta", "partial_json": ' "x"}'}}},
        {"content": {"type": "message_delta", "delta": {"stop_reason": "tool_use"}, "usage": {"output_tokens": 20}}},
    ]
    result = _assemble_anthropic_chunks(chunks)
    assert result["model"] == "claude-3"
    assert result["stop_reason"] == "tool_use"
    assert result["usage"] == {"input_tokens": 10, "output_tokens": 20}
    assert result["blocks"][0]["type"] == "text"
    assert result["blocks"][0]["text"] == "Hi"
    tool_block = result["blocks"][1]
    assert tool_block["name"] == "read_file"
    assert tool_block["input"] == {"path": "x"}


def test_openai_responses_chunks() -> None:
    chunks = [
        {"content": {"type": "response.created", "response": {"model": "gpt-5", "status": "in_progress"}}},
        {"content": {"type": "response.output_text.delta", "delta": "The answer is "}},
        {"content": {"type": "response.output_text.delta", "delta": "42."}},
        {"content": {"type": "response.output_item.added", "output_index": 0, "item": {"type": "function_call", "call_id": "fc_1", "name": "read_file"}}},
        {"content": {"type": "response.function_call_arguments.delta", "output_index": 0, "delta": '{"path": "x"}'}},
        {"content": {"type": "response.completed", "response": {"status": "completed", "usage": {"total_tokens": 5}, "model": "gpt-5"}}},
    ]
    result = _assemble_openai_responses_chunks(chunks)
    assert result["content"] == "The answer is 42."
    assert result["status"] == "completed"
    assert result["usage"] == {"total_tokens": 5}
    assert result["model"] == "gpt-5"
    assert result["tool_calls"] == [{"id": "fc_1", "name": "read_file", "arguments": '{"path": "x"}', "type": "function_call"}]


def test_input_items() -> None:
    input_items = [
        {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "hi"}]},
        {"type": "message", "role": "assistant", "content": [{"type": "thinking", "text": "think"}]},
        {"type": "function_call", "call_id": "fc_1", "name": "read_file", "input": '{"path": "x"}'},
        {"type": "function_call_output", "call_id": "fc_1", "output": [{"type": "output_text", "text": "contents"}]},
        {"type": "reasoning", "summary": [{"type": "summary_text", "text": "reasoned"}]},
    ]
    messages = _summarize_responses_input(input_items)
    assert messages[0]["role"] == "user"
    assert messages[0]["parts"] == [{"type": "text", "text": "hi"}]
    assert messages[1]["parts"] == [{"type": "thinking", "text": "think"}]
    assert messages[2]["parts"][0]["type"] == "tool_use"
    assert messages[2]["parts"][0]["name"] == "read_file"
    assert messages[2]["parts"][0]["input"] == {"path": "x"}
    assert messages[3]["parts"][0]["content"] == "contents"
    assert messages[4]["parts"] == [{"type": "thinking", "text": "reasoned"}]


def test_string_input_skipped() -> None:
    assert _summarize_responses_input("plain question") == []


def test_simple_diff() -> None:
    diff, is_binary = _generate_diff(b"a\nb\n", b"a\nc\n", "dir/file.txt")
    assert not is_binary
    assert diff is not None
    assert "-b" in diff or ""
    assert "+c" in diff or ""
    assert "file.txt" in diff or ""


def test_identical_content() -> None:
    diff, is_binary = _generate_diff(b"same", b"same", "f")
    assert diff is None
    assert not is_binary


def test_binary_content() -> None:
    diff, is_binary = _generate_diff(b"\x00\xff\xfe", b"\x00\x01", "f.bin")
    assert diff is None
    assert is_binary


def test_created_and_deleted() -> None:
    diff, is_binary = _generate_diff(None, b"new\n", "new.txt")
    assert diff is not None
    assert not is_binary
    diff, is_binary = _generate_diff(b"old\n", None, "gone.txt")
    assert diff is not None
    assert not is_binary


def _write(path: Path, text: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(text, encoding="utf-8")


def test_parse_chat_and_http(tmp_path) -> None:
    session_dir = tmp_path / "sess1"
    lines = [
        json.dumps({"type": "request", "id": "req1", "timestamp": "2026-01-01T00:00:00Z", "method": "POST", "url": "https://api.openai.com/v1/chat/completions", "body": {"model": "gpt-4o", "messages": [{"role": "user", "content": "hello"}], "stream": True}}),
        json.dumps({"type": "response_chunk", "request_id": "req1", "timestamp": "2026-01-01T00:00:01Z", "content": {"choices": [{"delta": {"content": "hi"}}]}}),
        json.dumps({"type": "response_chunk", "request_id": "req1", "timestamp": "2026-01-01T00:00:02Z", "content": {"choices": [{"delta": {}}, {"finish_reason": "stop"}]}}),
        json.dumps({"type": "request", "id": "req2", "timestamp": "2026-01-01T00:00:03Z", "method": "GET", "url": "https://models.opencode.ai/api.json", "body": None}),
        json.dumps({"type": "response", "request_id": "req2", "timestamp": "2026-01-01T00:00:04Z", "status_code": 200, "body": {"ok": True}}),
    ]
    _write(session_dir / "traces" / "captured.jsonl", "\n".join(lines) + "\n")

    result = parse_traces(session_dir)
    assert len(result["chat_calls"]) == 1
    assert len(result["http_calls"]) == 1

    chat = result["chat_calls"][0]
    assert chat["model"] == "gpt-4o"
    assert chat["stream"]
    assert chat["messages"][0]["role"] == "user"
    assert chat["messages"][0]["parts"] == [{"type": "text", "text": "hello"}]
    assert chat["response"]["parts"][0]["text"] == "hi"
    assert chat["response"]["stop_reason"] == "stop"

    http = result["http_calls"][0]
    assert http["method"] == "GET"
    assert http["status_code"] == 200


def test_missing_traces_dir(tmp_path) -> None:
    session_dir = tmp_path / "sess1"
    assert parse_traces(session_dir) == {"chat_calls": [], "http_calls": []}


def test_ignores_bad_lines(tmp_path) -> None:
    session_dir = tmp_path / "sess1"
    _write(session_dir / "traces" / "captured.jsonl", "not json\n\n")
    assert parse_traces(session_dir) == {"chat_calls": [], "http_calls": []}


def test_list_sessions(tmp_path) -> None:
    sid = "abc123"
    base = tmp_path / sid
    _write(base / "nono-audit" / "sessions" / f"{sid}.json",
           json.dumps({"session_id": sid, "name": "s", "started": "2026-01-01T00:00:00+00:00", "command": ["opencode"], "exit_code": 0, "status": "exited"}))
    _write(base / "nono-audit" / "audit" / sid / "session.json",
           json.dumps({"started": "2026-01-01T00:00:00+00:00", "ended": "2026-01-01T01:00:00+00:00", "exit_code": 0}))
    _write(base / "nono-audit" / "audit" / sid / "audit-events.ndjson",
           '{"type": "session_started"}\n{"type": "session_ended"}\n')
    # Three real LLM chat-request traces. list_sessions prefers the computed
    # session summary, where trace_count counts parsed chat calls (requests
    # to a chat endpoint with a messages body) -- so the fixture must contain
    # actual chat requests, not arbitrary jsonl lines.
    trace_lines = "\n".join(
        json.dumps({
            "type": "request",
            "id": f"req{i}",
            "timestamp": f"2026-01-01T00:00:0{i}Z",
            "method": "POST",
            "url": "https://api.openai.com/v1/chat/completions",
            "body": {"model": "gpt-4o", "messages": [{"role": "user", "content": "hello"}]},
        })
        for i in range(3)
    )
    _write(base / "traces" / "captured.jsonl", trace_lines + "\n")

    sessions = list_sessions(tmp_path)
    assert len(sessions) == 1
    s = sessions[0]
    assert s["id"] == sid
    assert s["command"] == "opencode"
    assert s["exit_code"] == 0
    assert s["audit_event_count"] == 2
    assert s["trace_count"] == 3


def test_empty_sessions_dir(tmp_path) -> None:
    assert list_sessions(tmp_path) == []
    assert list_sessions(tmp_path / "missing") == []


def test_rollback_steps(tmp_path) -> None:
    rb = tmp_path / "nono-audit" / "rollbacks" / "rb1"
    _write(rb / "session.json", json.dumps({"session_id": "rb1"}))
    _write(rb / "changes" / "1.json", json.dumps([
        {"path": "/workspace/file.txt", "change_type": "modified", "old_hash": "aa1111", "new_hash": "bb2222", "size_delta": 5},
    ]))
    _write(rb / "objects" / "aa" / "1111", "old content")
    _write(rb / "objects" / "bb" / "2222", "new content")

    result = _load_rollback(tmp_path)
    assert result["available"]
    assert len(result["steps"]) == 1
    change = result["steps"][0]["changes"][0]
    assert change["path"] == "/workspace/file.txt"
    assert change["is_workspace"]
    assert "old content" in change["diff"] or ""
    assert "new content" in change["diff"] or ""


def test_no_rollbacks(tmp_path) -> None:
    assert _load_rollback(tmp_path) == {"available": False, "steps": []}
