"""Tests for hermes_cli.foreign_sessions — Claude Code / Codex CLI import.

Fixture JSONL is synthesized inline (tmp_path); the SessionDB is opened
against a temp path so nothing touches the real HERMES_HOME store.
"""

import json
from pathlib import Path

import pytest

from hermes_cli.foreign_sessions import (
    _list_sessions,
    gather_foreign_sessions,
    import_foreign_session,
    parse_claude_session,
    parse_codex_session,
)


# ── fixture builders ─────────────────────────────────────────────────────


def _claude_lines():
    def msg(role, content):
        return {
            "type": role,
            "sessionId": "abc-123",
            "cwd": "/home/user/proj",
            "message": {"role": role, "content": content},
        }

    return [
        {"type": "summary", "summary": "Fix the flaky test"},
        msg("user", "Please fix the flaky test in CI."),
        msg(
            "assistant",
            [
                {"type": "text", "text": "Looking into it now."},
                {"type": "tool_use", "name": "Bash", "id": "t1", "input": {}},
            ],
        ),
        # tool_result echoed back as a user message — must NOT become a turn
        msg("user", [{"type": "tool_result", "tool_use_id": "t1", "content": "ok"}]),
        msg("assistant", [{"type": "text", "text": "Fixed — the sleep was too short."}]),
        msg("user", "Great, thanks!"),
        msg("assistant", [{"type": "text", "text": "Anytime."}]),
    ]


def _write_claude_fixture(tmp_path, extra_lines=None):
    proj = tmp_path / ".claude" / "projects" / "-home-user-proj"
    proj.mkdir(parents=True)
    f = proj / "abc-123.jsonl"
    lines = [json.dumps(entry) for entry in _claude_lines()]
    if extra_lines:
        lines = extra_lines + lines
    f.write_text("\n".join(lines) + "\n", encoding="utf-8")
    return f


def _codex_lines():
    def item(payload):
        return {"timestamp": "2026-08-15T21:35:28Z", "type": "response_item", "payload": payload}

    def message(role, kind, text):
        return item({"type": "message", "role": role, "content": [{"type": kind, "text": text}]})

    return [
        {
            "type": "session_meta",
            "payload": {"session_id": "0000-1111", "cwd": "/home/user/repo"},
        },
        # developer/system payloads must never be imported
        message("developer", "input_text", "<skills_instructions>secret system stuff"),
        message("user", "input_text", "<recommended_plugins>\ninjected wrapper"),
        message("user", "input_text", "Summarize the transcripts please."),
        message("assistant", "output_text", "Reading them one at a time."),
        item({"type": "custom_tool_call", "name": "shell", "call_id": "c1"}),
        item({"type": "custom_tool_call_output", "call_id": "c1", "output": "big output"}),
        message("assistant", "output_text", "Done — here is the summary."),
        message("user", "input_text", "Now write it to a file."),
        message("assistant", "output_text", "Written to summary.md."),
    ]


def _write_codex_fixture(tmp_path, extra_lines=None):
    day = tmp_path / ".codex" / "sessions" / "2026" / "08" / "15"
    day.mkdir(parents=True)
    f = day / "rollout-2026-08-15T21-35-28-0000-1111.jsonl"
    lines = [json.dumps(entry) for entry in _codex_lines()]
    if extra_lines:
        lines = extra_lines + lines
    f.write_text("\n".join(lines) + "\n", encoding="utf-8")
    return f


@pytest.fixture
def session_db(tmp_path):
    from hermes_state import SessionDB

    db = SessionDB(db_path=tmp_path / "state.db")
    yield db
    db.close()


def _assert_alternating(messages):
    roles = [m["role"] for m in messages]
    assert roles, "no messages"
    assert roles[0] == "user"
    for a, b in zip(roles, roles[1:]):
        assert a != b, f"two consecutive {a} messages"


# ── parsing ──────────────────────────────────────────────────────────────


def test_parse_claude_session(tmp_path):
    f = _write_claude_fixture(tmp_path)
    parsed = parse_claude_session(f)
    turns = parsed["turns"]
    _assert_alternating(turns)
    assert len(turns) == 4  # tool_result-only user line skipped; assistants merge
    assert parsed["cwd"] == "/home/user/proj"
    assert parsed["title_guess"] == "Fix the flaky test"
    assert parsed["session_id"] == "abc-123"
    # tool_use flattened to a bracketed summary, merged with adjacent text
    joined_assistant = "\n".join(t["content"] for t in turns if t["role"] == "assistant")
    assert "[ran tool: Bash]" in joined_assistant
    # no fabricated tool_call structures
    assert all(set(t) == {"role", "content"} for t in turns)


def test_parse_codex_session(tmp_path):
    f = _write_codex_fixture(tmp_path)
    parsed = parse_codex_session(f)
    turns = parsed["turns"]
    _assert_alternating(turns)
    assert len(turns) == 4
    assert parsed["cwd"] == "/home/user/repo"
    assert parsed["session_id"] == "0000-1111"
    assert parsed["title_guess"].startswith("Summarize the transcripts")
    # developer + wrapper user lines excluded
    all_text = "\n".join(t["content"] for t in turns)
    assert "skills_instructions" not in all_text
    assert "recommended_plugins" not in all_text
    # tool call summarized in assistant text
    assert "[ran tool: shell]" in all_text


def test_malformed_lines_are_skipped(tmp_path):
    garbage = [
        "this is not json at all {{{",
        '"just a string"',
        json.dumps({"type": "user", "message": "not-a-dict-payload... wait, string"}),
        json.dumps({"type": "user"}),  # missing message
        "",
    ]
    f = _write_claude_fixture(tmp_path, extra_lines=garbage)
    parsed = parse_claude_session(f)
    assert len(parsed["turns"]) == 4  # good lines still parse

    g = _write_codex_fixture(tmp_path, extra_lines=["not json", json.dumps({"type": "response_item"})])
    parsed_codex = parse_codex_session(g)
    assert len(parsed_codex["turns"]) == 4


# ── discovery ────────────────────────────────────────────────────────────


def test_list_sessions(tmp_path):
    _write_claude_fixture(tmp_path)
    _write_codex_fixture(tmp_path)
    claude = _list_sessions("claude", tmp_path / ".claude" / "projects")
    codex = _list_sessions("codex", tmp_path / ".codex" / "sessions")
    assert len(claude) == 1 and claude[0].source == "claude"
    assert claude[0].turn_count == 4
    assert len(codex) == 1 and codex[0].source == "codex"
    assert codex[0].turn_count == 4
    both = gather_foreign_sessions(
        claude_root=tmp_path / ".claude" / "projects",
        codex_root=tmp_path / ".codex" / "sessions",
    )
    assert len(both) == 2
    assert both[0].mtime >= both[1].mtime  # newest first


def test_list_sessions_missing_roots(tmp_path):
    assert _list_sessions("claude", tmp_path / "nope") == []
    assert _list_sessions("codex", tmp_path / "nope") == []


def test_env_overrides_relocate_default_roots(tmp_path, monkeypatch):
    """CLAUDE_CONFIG_DIR / CODEX_HOME relocate discovery (ported from cline/cline#13827)."""
    claude_cfg = tmp_path / "relocated-claude"
    codex_home = tmp_path / "relocated-codex"
    _write_claude_fixture(tmp_path)  # writes under tmp_path/.claude — becomes the store root below
    (tmp_path / ".claude").rename(claude_cfg)
    _write_codex_fixture(tmp_path)
    (tmp_path / ".codex").rename(codex_home)
    monkeypatch.setattr(Path, "home", lambda: tmp_path)  # default roots are empty
    monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(claude_cfg))
    monkeypatch.setenv("CODEX_HOME", str(codex_home))
    both = gather_foreign_sessions()
    assert {s.source for s in both} == {"claude", "codex"}


def test_blank_env_overrides_fall_back_to_home(tmp_path, monkeypatch):
    """A blank/whitespace override is unset, not a CWD-relative path (cline/cline#13827)."""
    _write_claude_fixture(tmp_path)
    _write_codex_fixture(tmp_path)
    monkeypatch.setattr(Path, "home", lambda: tmp_path)
    monkeypatch.setenv("CLAUDE_CONFIG_DIR", "")
    monkeypatch.setenv("CODEX_HOME", "   ")
    both = gather_foreign_sessions()
    assert {s.source for s in both} == {"claude", "codex"}


# ── import into SessionDB ────────────────────────────────────────────────


def test_import_claude_session(tmp_path, session_db):
    f = _write_claude_fixture(tmp_path)
    session_id = import_foreign_session("claude", f, db=session_db)
    row = session_db.get_session(session_id)
    assert row is not None
    assert row["source"] == "claude-code"
    assert row["cwd"] == "/home/user/proj"
    assert row["message_count"] == 4
    title = session_db.get_session_title(session_id)
    assert title.startswith("Imported from Claude Code: ")
    assert "Please fix the flaky test" in title
    messages = session_db.get_messages(session_id)
    assert len(messages) == 4
    _assert_alternating(messages)
    origin = json.loads(row["origin_json"])
    assert origin["imported_from"]["tool"] == "claude-code"
    assert origin["imported_from"]["path"] == str(f)


def test_import_codex_session(tmp_path, session_db):
    f = _write_codex_fixture(tmp_path)
    session_id = import_foreign_session("@codex", f, db=session_db)
    row = session_db.get_session(session_id)
    assert row is not None
    assert row["source"] == "codex-cli"
    assert row["message_count"] == 4
    title = session_db.get_session_title(session_id)
    assert title.startswith("Imported from Codex CLI: ")
    messages = session_db.get_messages(session_id)
    _assert_alternating(messages)
    # resumable: resolve_session_id round-trips
    assert session_db.resolve_session_id(session_id) == session_id


def test_import_rejects_bad_input(tmp_path, session_db):
    with pytest.raises(ValueError, match="Unknown foreign session source"):
        import_foreign_session("gemini", tmp_path / "x.jsonl", db=session_db)
    with pytest.raises(ValueError, match="not found"):
        import_foreign_session("claude", tmp_path / "missing.jsonl", db=session_db)
    empty = tmp_path / "empty.jsonl"
    empty.write_text("not json\n", encoding="utf-8")
    with pytest.raises(ValueError, match="No user/assistant conversation"):
        import_foreign_session("claude", empty, db=session_db)


def test_leading_assistant_gets_single_stub(tmp_path):
    day = tmp_path / ".codex" / "sessions" / "2026" / "01" / "01"
    day.mkdir(parents=True)
    f = day / "rollout-x.jsonl"
    lines = [
        json.dumps(
            {
                "type": "response_item",
                "payload": {
                    "type": "message",
                    "role": "assistant",
                    "content": [{"type": "output_text", "text": "Continuing from before."}],
                },
            }
        )
    ]
    f.write_text("\n".join(lines) + "\n", encoding="utf-8")
    parsed = parse_codex_session(f)
    _assert_alternating(parsed["turns"])
    assert len(parsed["turns"]) == 2
    assert parsed["turns"][0]["role"] == "user"


def test_whitespace_only_user_turn_does_not_break_discovery(tmp_path):
    """A blank user message (image-only / tool-only turn) must not crash listing; the title
    comes from the first non-blank user line and the blank turn is dropped."""
    project = tmp_path / ".claude" / "projects" / "p"
    project.mkdir(parents=True)
    f = project / "blank.jsonl"
    lines = [
        {"type": "user", "sessionId": "w", "message": {"role": "user", "content": "   \n  "}},
        {"type": "assistant", "sessionId": "w", "message": {"role": "assistant", "content": "hi"}},
        {"type": "user", "sessionId": "w", "message": {"role": "user", "content": "real question"}},
    ]
    f.write_text("\n".join(json.dumps(line) for line in lines) + "\n", encoding="utf-8")
    listed = _list_sessions("claude", project.parent)
    assert [s.title_guess for s in listed] == ["real question"]
    assert listed[0].turn_count == 3  # leading assistant reply gets the user stub
