# SPDX-License-Identifier: AGPL-3.0-only
# Copyright (c) 2026 sol pbc

"""Tests for the journal indexer CLI."""

from __future__ import annotations

import json
import runpy
import subprocess
import sys
from collections import Counter
from pathlib import Path
from typing import Any

import pytest

import solstone.think.journal_config as journal_config
import solstone.think.utils as think_utils
from solstone.think import core_handshake, sol_cli
from solstone.think.indexer import cli as indexer_cli
from solstone.think.indexer import native as indexer_native
from solstone.think.indexer.journal import ScanReport


def test_module_entrypoint_propagates_main_return(monkeypatch):
    monkeypatch.setattr(indexer_cli, "main", lambda: 75)

    with pytest.raises(SystemExit) as exc_info:
        runpy.run_module("solstone.think.indexer.__main__", run_name="__main__")

    assert exc_info.value.code == 75


def _setup_cli_for(args: list[str]):
    def setup_cli(parser):
        parsed = parser.parse_args(args)
        if not hasattr(parsed, "verbose"):
            parsed.verbose = False
        if not hasattr(parsed, "debug"):
            parsed.debug = False
        return parsed

    return setup_cli


def _run_indexer_cli(
    monkeypatch: pytest.MonkeyPatch,
    journal: Path,
    args: list[str],
) -> int | None:
    monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal))
    think_utils._journal_path_cache = None
    monkeypatch.setattr(indexer_cli, "get_journal", lambda: str(journal))
    monkeypatch.setattr(indexer_cli, "require_solstone", lambda: None)
    monkeypatch.setattr(indexer_cli, "setup_cli", _setup_cli_for(args))
    return indexer_cli.main()


def _install_python_write_recorders(monkeypatch: pytest.MonkeyPatch) -> list[str]:
    calls: list[str] = []

    def scan_journal(
        _journal: str,
        *,
        verbose: bool = False,
        full: bool = False,
    ) -> ScanReport:
        calls.append(f"scan_journal:{verbose}:{full}")
        return ScanReport(changed=True, edge_rows_inserted=1)

    def index_file(_journal: str, _path: str, *, verbose: bool = False) -> bool:
        calls.append(f"index_file:{verbose}")
        return True

    def rebuild_edges(_journal: str) -> dict[str, int]:
        calls.append("rebuild_edges")
        return {"files": 1, "rows": 1, "drops": 0, "failed": 0}

    monkeypatch.setattr(indexer_cli, "scan_journal", scan_journal, raising=False)
    monkeypatch.setattr(indexer_cli, "index_file", index_file, raising=False)
    monkeypatch.setattr(indexer_cli, "rebuild_edges", rebuild_edges, raising=False)
    return calls


def _native_argv_from_args(args: Any, journal: str) -> list[str]:
    flags: list[str] = []
    if args.reset:
        flags.append("--reset")
    if args.rebuild_edges:
        flags.append("--rebuild-edges")
    if args.rescan_file:
        flags.extend(["--rescan-file", args.rescan_file])
    elif args.rescan_full:
        flags.append("--rescan-full")
    elif args.rescan:
        flags.append("--rescan")
    return ["/tmp/bin/solstone-core", "indexer", "--journal", journal, *flags]


def _install_native_stub(
    monkeypatch: pytest.MonkeyPatch,
    native_argvs: list[list[str]],
    *,
    returncode: int = 0,
) -> None:
    def run_native_indexer(args: Any, journal: str) -> int:
        native_argvs.append(_native_argv_from_args(args, journal))
        return returncode

    monkeypatch.setattr(indexer_cli, "run_native_indexer", run_native_indexer)


def _install_query_stubs(monkeypatch: pytest.MonkeyPatch) -> list[str]:
    calls: list[str] = []

    def search_counts(query: str, **_kwargs: Any) -> dict[str, Any]:
        calls.append(f"counts:{query}")
        return {
            "total": 1,
            "facets": Counter({"work": 1}),
            "agents": Counter({"flow": 1}),
            "days": Counter({"20260101": 1}),
        }

    def search_journal(
        query: str,
        limit: int,
        offset: int,
        **_kwargs: Any,
    ) -> tuple[int, list[dict[str, Any]]]:
        calls.append(f"search:{query}:{limit}:{offset}")
        return (
            1,
            [
                {
                    "metadata": {
                        "day": "20260101",
                        "agent": "flow",
                        "facet": "work",
                    },
                    "text": f"found {query}",
                }
            ],
        )

    monkeypatch.setattr(indexer_cli, "search_counts", search_counts)
    monkeypatch.setattr(indexer_cli, "search_journal", search_journal)
    return calls


def _install_no_config_handshake_or_subprocess(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    def fail(name: str):
        def raise_unexpected(*_args: Any, **_kwargs: Any):
            raise AssertionError(f"{name} should not be called")

        return raise_unexpected

    monkeypatch.setattr(
        journal_config,
        "read_journal_config",
        fail("read_journal_config"),
    )
    monkeypatch.setattr(
        core_handshake,
        "check_solstone_core_handshake",
        fail("check_solstone_core_handshake"),
    )
    monkeypatch.setattr(subprocess, "run", fail("subprocess.run"))
    monkeypatch.setattr(indexer_cli, "run_native_indexer", fail("run_native_indexer"))


def test_help_no_work_uses_python_help_without_config_handshake_or_subprocess(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
    capsys: pytest.CaptureFixture[str],
) -> None:
    journal = tmp_path / "journal"
    journal.mkdir()
    _install_no_config_handshake_or_subprocess(monkeypatch)

    result = _run_indexer_cli(monkeypatch, journal, [])

    captured = capsys.readouterr()
    assert result is None
    assert "usage:" in captured.out
    assert "Index journal content" in captured.out
    assert captured.err == ""


def test_query_only_uses_python_search_without_config_handshake_or_subprocess(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
    capsys: pytest.CaptureFixture[str],
) -> None:
    journal = tmp_path / "journal"
    journal.mkdir()
    _install_no_config_handshake_or_subprocess(monkeypatch)
    query_calls = _install_query_stubs(monkeypatch)

    result = _run_indexer_cli(monkeypatch, journal, ["-q", "needle"])

    captured = capsys.readouterr()
    assert result is None
    assert query_calls == ["counts:needle", "search:needle:10:0"]
    assert "Total: 1 chunks" in captured.out
    assert "found needle" in captured.out
    assert captured.err == ""


def test_interactive_query_uses_python_search_without_config_handshake_or_subprocess(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
    capsys: pytest.CaptureFixture[str],
) -> None:
    journal = tmp_path / "journal"
    journal.mkdir()
    _install_no_config_handshake_or_subprocess(monkeypatch)
    query_calls = _install_query_stubs(monkeypatch)
    inputs = iter(["needle", ""])
    monkeypatch.setattr("builtins.input", lambda _prompt: next(inputs))

    result = _run_indexer_cli(monkeypatch, journal, ["-q"])

    captured = capsys.readouterr()
    assert result is None
    assert query_calls == ["counts:needle", "search:needle:10:0"]
    assert "Total: 1 chunks" in captured.out
    assert "found needle" in captured.out
    assert captured.err == ""


def test_write_only_routes_to_native_without_python_writes(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    journal = tmp_path / "journal"
    journal.mkdir()
    native_argvs: list[list[str]] = []
    python_write_calls = _install_python_write_recorders(monkeypatch)
    _install_native_stub(monkeypatch, native_argvs)

    result = _run_indexer_cli(monkeypatch, journal, ["--rescan"])

    assert result == 0
    assert native_argvs == [
        ["/tmp/bin/solstone-core", "indexer", "--journal", str(journal), "--rescan"]
    ]
    assert python_write_calls == []


def test_mixed_write_query_runs_query_after_native_success(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
    capsys: pytest.CaptureFixture[str],
) -> None:
    journal = tmp_path / "journal"
    journal.mkdir()
    native_argvs: list[list[str]] = []
    python_write_calls = _install_python_write_recorders(monkeypatch)
    _install_native_stub(monkeypatch, native_argvs)
    query_calls = _install_query_stubs(monkeypatch)

    result = _run_indexer_cli(
        monkeypatch,
        journal,
        ["--rescan", "--query", "needle"],
    )

    captured = capsys.readouterr()
    assert result is None
    assert native_argvs == [
        ["/tmp/bin/solstone-core", "indexer", "--journal", str(journal), "--rescan"]
    ]
    assert python_write_calls == []
    assert query_calls == ["counts:needle", "search:needle:10:0"]
    assert "Total: 1 chunks" in captured.out
    assert "found needle" in captured.out


def test_mixed_empty_query_enters_interactive_after_native_success(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
    capsys: pytest.CaptureFixture[str],
) -> None:
    journal = tmp_path / "journal"
    journal.mkdir()
    native_argvs: list[list[str]] = []
    python_write_calls = _install_python_write_recorders(monkeypatch)
    _install_native_stub(monkeypatch, native_argvs)
    query_calls = _install_query_stubs(monkeypatch)
    inputs = iter(["needle", ""])
    monkeypatch.setattr("builtins.input", lambda _prompt: next(inputs))

    result = _run_indexer_cli(monkeypatch, journal, ["--rescan", "-q"])

    captured = capsys.readouterr()
    assert result is None
    assert native_argvs == [
        ["/tmp/bin/solstone-core", "indexer", "--journal", str(journal), "--rescan"]
    ]
    assert python_write_calls == []
    assert query_calls == ["counts:needle", "search:needle:10:0"]
    assert "found needle" in captured.out


def test_mixed_write_query_native_failure_skips_query(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
    capsys: pytest.CaptureFixture[str],
) -> None:
    journal = tmp_path / "journal"
    journal.mkdir()
    native_argvs: list[list[str]] = []
    python_write_calls = _install_python_write_recorders(monkeypatch)
    _install_native_stub(monkeypatch, native_argvs, returncode=75)
    query_calls = _install_query_stubs(monkeypatch)

    result = _run_indexer_cli(
        monkeypatch,
        journal,
        ["--rescan", "--query", "needle"],
    )

    captured = capsys.readouterr()
    assert result == 75
    assert native_argvs == [
        ["/tmp/bin/solstone-core", "indexer", "--journal", str(journal), "--rescan"]
    ]
    assert python_write_calls == []
    assert query_calls == []
    assert captured.out == ""


def test_rescan_file_with_scan_exits_64_before_side_effects(
    monkeypatch: pytest.MonkeyPatch,
    capsys: pytest.CaptureFixture[str],
) -> None:
    python_write_calls = _install_python_write_recorders(monkeypatch)
    query_calls = _install_query_stubs(monkeypatch)
    monkeypatch.setattr(
        indexer_cli,
        "setup_cli",
        _setup_cli_for(
            ["--rescan", "--rescan-file", "chronicle/today.md", "--query", "needle"]
        ),
    )
    monkeypatch.setattr(
        indexer_cli,
        "require_solstone",
        lambda: pytest.fail("require_solstone should not run"),
    )
    monkeypatch.setattr(
        indexer_cli,
        "get_journal",
        lambda: pytest.fail("get_journal should not run"),
    )
    monkeypatch.setattr(
        indexer_cli,
        "run_native_indexer",
        lambda *_args, **_kwargs: pytest.fail("native should not run"),
    )

    result = indexer_cli.main()

    captured = capsys.readouterr()
    assert result == 64
    assert python_write_calls == []
    assert query_calls == []
    assert captured.err.strip() == (
        "journal indexer usage error: --rescan-file cannot be combined with "
        "--rescan or --rescan-full."
    )


@pytest.mark.parametrize(
    "args",
    [
        ["--rescan"],
        ["--rescan", "--query", "needle"],
    ],
)
def test_stale_core_indexer_keys_are_inert(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
    capsys: pytest.CaptureFixture[str],
    args: list[str],
) -> None:
    journal = tmp_path / "journal"
    journal.mkdir()
    native_argvs: list[list[str]] = []
    _install_python_write_recorders(monkeypatch)
    _install_native_stub(monkeypatch, native_argvs)
    _install_query_stubs(monkeypatch)

    def fail_read_journal_config(*_args: Any, **_kwargs: Any) -> dict[str, Any]:
        raise AssertionError("read_journal_config should not be touched")

    monkeypatch.setattr(
        journal_config,
        "read_journal_config",
        fail_read_journal_config,
    )

    first_result = _run_indexer_cli(monkeypatch, journal, args)
    first_output = capsys.readouterr()

    config_path = journal / "config" / "journal.json"
    config_path.parent.mkdir(parents=True)
    config_path.write_text(
        json.dumps(
            {
                "core": {
                    "indexer": "python",
                    "indexer_on_decline": "fallback",
                }
            }
        )
        + "\n",
        encoding="utf-8",
    )

    second_result = _run_indexer_cli(monkeypatch, journal, args)
    second_output = capsys.readouterr()

    assert first_result == second_result
    assert native_argvs[0] == native_argvs[1]
    assert first_output.out == second_output.out
    assert first_output.err == second_output.err


@pytest.mark.parametrize(
    "args",
    [
        ["--rescan"],
        ["--rescan-full"],
        ["--rebuild-edges"],
        ["--rescan-file", "chronicle/today.md"],
    ],
)
def test_rescan_leaves_existing_root_task_log_unchanged(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
    args: list[str],
) -> None:
    journal = tmp_path / "journal"
    journal.mkdir()
    root_log = journal / "task_log.txt"
    original = b"123\tkeep\n"
    root_log.write_bytes(original)
    native_argvs: list[list[str]] = []
    python_write_calls = _install_python_write_recorders(monkeypatch)
    _install_native_stub(monkeypatch, native_argvs)

    result = _run_indexer_cli(monkeypatch, journal, args)

    assert result == 0
    assert native_argvs == [
        ["/tmp/bin/solstone-core", "indexer", "--journal", str(journal), *args]
    ]
    assert python_write_calls == []
    assert root_log.read_bytes() == original


def _dispatcher_native_impl(
    *, native_returncode: int | None = None, launch_error=False
):
    def run_native_indexer(args: Any, journal: str) -> int:
        def native_runner(argv: list[str], *, check: bool = False):
            assert check is False
            if launch_error:
                raise OSError("missing helper")
            return subprocess.CompletedProcess(argv, native_returncode)

        return indexer_native.run_native_indexer(
            args,
            journal,
            handshake_checker=lambda: core_handshake.CoreHandshakeResult("ok"),
            helper_locator=lambda: Path("/tmp/bin/solstone-core"),
            native_runner=native_runner,
            platform_reader=lambda: ("linux", "x86_64"),
            platform_tag_reader=lambda: {"manylinux2014_x86_64"},
        )

    return run_native_indexer


def _run_dispatcher(
    monkeypatch: pytest.MonkeyPatch,
    journal: Path,
    args: list[str],
    run_native_indexer,
) -> int:
    monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal))
    think_utils._journal_path_cache = None
    monkeypatch.setattr(indexer_cli, "get_journal", lambda: str(journal))
    monkeypatch.setattr(indexer_cli, "require_solstone", lambda: None)
    monkeypatch.setattr(indexer_cli, "setup_cli", _setup_cli_for(args))
    monkeypatch.setattr(indexer_cli, "run_native_indexer", run_native_indexer)
    monkeypatch.setattr(sys, "argv", ["journal", "indexer", *args])
    return sol_cli.run_command("solstone.think.indexer")


@pytest.mark.parametrize(
    ("case", "run_native_indexer", "expected"),
    [
        ("success", _dispatcher_native_impl(native_returncode=0), 0),
        ("native_69", _dispatcher_native_impl(native_returncode=69), 69),
        ("native_tempfail", _dispatcher_native_impl(native_returncode=75), 75),
        ("native_signal", _dispatcher_native_impl(native_returncode=-9), 75),
        ("native_launch", _dispatcher_native_impl(launch_error=True), 75),
        ("native_other", _dispatcher_native_impl(native_returncode=12), 12),
    ],
)
def test_dispatcher_exit_contract_for_native_results(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
    case: str,
    run_native_indexer,
    expected: int,
) -> None:
    journal = tmp_path / case
    journal.mkdir()

    result = _run_dispatcher(
        monkeypatch,
        journal,
        ["--rescan"],
        run_native_indexer,
    )

    assert result == expected


def test_dispatcher_exit_contract_for_usage_64(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    journal = tmp_path / "journal"
    journal.mkdir()

    result = _run_dispatcher(
        monkeypatch,
        journal,
        ["--rescan", "--rescan-file", "chronicle/today.md"],
        lambda *_args, **_kwargs: pytest.fail("native should not run"),
    )

    assert result == 64
