from pathlib import Path

import polars as pl
import pytest

from pulka.api.runtime import Runtime
from pulka.api.session import Session
from pulka.command.registry import REGISTRY, CommandRegistry
from pulka.command.runtime import SessionCommandRuntime
from pulka.logging import Recorder, RecorderConfig
from pulka.sheets.file_browser_sheet import FileBrowserSheet
from pulka.testing.data import make_df, write_df
from pulka.tui.screen import Screen


@pytest.fixture
def sample_dataset(tmp_path):
    df = make_df("mini_nav", rows=8, cols=3, seed=123)
    path = tmp_path / "runtime.parquet"
    write_df(df, path, "parquet")
    return str(path)


def _make_recorder(tmp_path, name: str) -> Recorder:
    config = RecorderConfig(
        enabled=True,
        output_dir=tmp_path / name,
        buffer_size=128,
        cell_redaction="none",
    )
    return Recorder(config)


def _command_events(recorder: Recorder) -> list[dict[str, object]]:
    return [event.payload for event in recorder.iter_events() if event.type == "command"]


def _session_without_entrypoints(path: str, *, recorder: Recorder | None = None) -> Session:
    runtime = Runtime(load_entry_points=False)
    return runtime.open(path, viewport_rows=6, recorder=recorder)


def _browser_session(tmp_path: Path) -> Session:
    runtime = Runtime(load_entry_points=False)
    browser_sheet = FileBrowserSheet(tmp_path, scanners=runtime.scanners, runner=runtime.job_runner)
    return Session(None, initial_sheet=browser_sheet, runtime=runtime)


def _row_for_path(sheet: FileBrowserSheet, target: Path) -> int:
    total = int(sheet.row_count() or 0)
    if total <= 0:
        raise AssertionError("browser sheet has no rows")
    for idx in range(total):
        try:
            value = sheet.value_at(idx, "path")
        except Exception:  # pragma: no cover - defensive
            continue
        if value is not None and str(value) == str(target):
            return idx
    raise AssertionError(f"path not found in browser: {target}")


def test_session_exposes_command_runtime(sample_dataset):
    session = _session_without_entrypoints(sample_dataset)
    runtime = session.command_runtime
    assert isinstance(runtime, SessionCommandRuntime)

    viewer = runtime.prepare_viewer(session.viewer)
    assert viewer is session.viewer

    result = runtime.invoke("move_down", source="test", context_mutator=lambda ctx: None)
    assert result.dispatch is not None
    assert result.dispatch.spec.name == "move_down"
    assert result.render.should_render is True


def test_runtime_rejects_repeat_for_non_repeatable_command(sample_dataset):
    session = _session_without_entrypoints(sample_dataset)
    runtime = session.command_runtime

    result = runtime.invoke("schema", repeat=2, source="test", context_mutator=lambda ctx: None)
    assert result.dispatch is None
    assert result.message is not None
    assert "does not support repeat" in result.message


def test_runtime_dispatch_raw_reports_unknown_command(sample_dataset):
    session = _session_without_entrypoints(sample_dataset)
    runtime = session.command_runtime

    result = runtime.dispatch_raw("not-a-command", source="test")

    assert result.dispatch is None
    assert result.render.should_render is False
    assert result.message is not None
    assert "Unknown command" in result.message


def test_runtime_restores_previous_registry_binding(sample_dataset):
    session = _session_without_entrypoints(sample_dataset)
    runtime = session.command_runtime

    sentinel_registry = CommandRegistry(load_builtin_commands=False)
    REGISTRY.bind(sentinel_registry)
    try:
        runtime.invoke("move_down", source="test", context_mutator=lambda ctx: None)
        assert REGISTRY._current() is sentinel_registry
    finally:
        REGISTRY.bind(None)


def test_prepare_viewer_installs_recorder_callbacks(sample_dataset, tmp_path, monkeypatch):
    recorder = _make_recorder(tmp_path, "runtime-perf")
    session = _session_without_entrypoints(sample_dataset, recorder=recorder)
    runtime = session.command_runtime

    captured: list[object] = []

    def _capture(callback):
        captured.append(callback)

    monkeypatch.setattr(session.viewer, "set_perf_callback", _capture)
    runtime.prepare_viewer(session.viewer)

    assert captured and callable(captured[-1])

    recorder.disable()
    runtime.prepare_viewer(session.viewer)

    assert captured[-1] is None


def test_runtime_records_command_events_for_headless_and_tui(sample_dataset, tmp_path):
    headless_recorder = _make_recorder(tmp_path, "headless-rec")
    session_headless = _session_without_entrypoints(sample_dataset, recorder=headless_recorder)
    session_headless.run_script(["move_down"], auto_render=False)
    headless_events = _command_events(headless_recorder)
    assert any(
        event.get("source") == "headless" and event.get("name") == "move_down"
        for event in headless_events
    )

    tui_recorder = _make_recorder(tmp_path, "tui-rec")
    session_tui = _session_without_entrypoints(sample_dataset, recorder=tui_recorder)
    screen = Screen(session_tui.viewer, recorder=tui_recorder)
    screen._execute_command("move_down")
    tui_events = _command_events(tui_recorder)

    def _is_move_down(event) -> bool:
        return event.get("source") == "tui" and event.get("name") == "move_down"

    assert any(_is_move_down(event) for event in tui_events)

    unsubscribe = getattr(screen, "_view_stack_unsubscribe", None)
    if unsubscribe is not None:
        unsubscribe()


def test_browse_command_defaults_to_dataset_parent(sample_dataset):
    session = _session_without_entrypoints(sample_dataset)
    runtime = session.command_runtime

    result = runtime.invoke("file_browser_sheet", source="test")

    assert result.dispatch is not None
    assert result.dispatch.spec.name == "file_browser_sheet"
    sheet = session.viewer.sheet
    assert isinstance(sheet, FileBrowserSheet)
    assert sheet.directory == Path(sample_dataset).parent
    assert session.dataset_path is None


def test_browse_command_accepts_explicit_directory(sample_dataset, tmp_path):
    session = _session_without_entrypoints(sample_dataset)
    runtime = session.command_runtime
    explicit_dir = tmp_path / "nested"
    explicit_dir.mkdir()

    runtime.invoke("file_browser_sheet", args=[str(explicit_dir)], source="test")

    sheet = session.viewer.sheet
    assert isinstance(sheet, FileBrowserSheet)
    assert sheet.directory == explicit_dir


def test_browse_aliases_resolve_to_browse(sample_dataset):
    session = _session_without_entrypoints(sample_dataset)
    runtime = session.command_runtime

    result = runtime.dispatch_raw("b", source="test")
    assert result.dispatch is not None
    assert result.dispatch.spec.name == "file_browser_sheet"
    assert isinstance(session.viewer.sheet, FileBrowserSheet)

    session = _session_without_entrypoints(sample_dataset)
    runtime = session.command_runtime
    result = runtime.dispatch_raw("browser", source="test")
    assert result.dispatch is not None
    assert result.dispatch.spec.name == "file_browser_sheet"
    assert isinstance(session.viewer.sheet, FileBrowserSheet)


def test_commands_opens_commands_sheet(sample_dataset):
    session = _session_without_entrypoints(sample_dataset)
    runtime = session.command_runtime

    result = runtime.invoke("help_sheet", source="test")

    assert result.dispatch is not None
    assert result.dispatch.spec.name == "help_sheet"

    from pulka.sheets.commands_sheet import CommandsSheet

    sheet = session.viewer.sheet
    assert isinstance(sheet, CommandsSheet)

    table_slice = sheet.fetch_slice(0, 200, ["command"])
    names = list(table_slice.column("command").values)
    assert "move_down" in names
    assert "search" in names
    assert "render" not in names


def test_browse_command_requires_directory_when_unavailable():
    df = make_df("mini_nav", rows=2, cols=2, seed=5).lazy()
    session = Session(None, lazyframe=df)
    runtime = session.command_runtime

    runtime.invoke("file_browser_sheet", source="test")

    assert not isinstance(session.viewer.sheet, FileBrowserSheet)
    assert session.viewer.status_message == (
        "browse requires a directory path or file-backed dataset"
    )


def test_cd_changes_workdir_and_records_override(sample_dataset, tmp_path, monkeypatch):
    target = tmp_path / "cd-target"
    target.mkdir()
    monkeypatch.chdir(tmp_path)

    session = _session_without_entrypoints(sample_dataset)
    runtime = session.command_runtime

    result = runtime.invoke("cd", args=[str(target)], source="test")

    assert result.dispatch is not None
    assert result.dispatch.spec.name == "cd"
    assert Path.cwd() == target.resolve()
    assert session.command_cwd == target.resolve()
    assert session.viewer.status_message.startswith("cwd ->")


def test_cd_resolves_relative_to_dataset_directory(sample_dataset, tmp_path, monkeypatch):
    dataset_dir = Path(sample_dataset).parent
    nested = dataset_dir / "nested"
    nested.mkdir()
    elsewhere = tmp_path / "elsewhere"
    elsewhere.mkdir()
    monkeypatch.chdir(elsewhere)

    session = _session_without_entrypoints(sample_dataset)
    runtime = session.command_runtime

    runtime.invoke("cd", args=["nested"], source="test")

    assert Path.cwd() == nested.resolve()
    assert session.command_cwd == nested.resolve()


def test_cd_rejects_missing_directory(sample_dataset, tmp_path, monkeypatch):
    monkeypatch.chdir(tmp_path)
    missing = tmp_path / "does-not-exist"

    session = _session_without_entrypoints(sample_dataset)
    runtime = session.command_runtime
    before = Path.cwd()

    runtime.invoke("cd", args=[str(missing)], source="test")

    assert Path.cwd() == before
    assert session.command_cwd is None
    assert "does not exist" in (session.viewer.status_message or "")


def test_compare_command_uses_browser_selection(tmp_path: Path) -> None:
    left_df = pl.DataFrame({"id": [1, 2, 3], "value": [10, 20, 30]})
    right_df = pl.DataFrame({"id": [1, 2, 3], "value": [10, 25, 30]})
    left_path = tmp_path / "left.parquet"
    right_path = tmp_path / "right.parquet"
    write_df(left_df, left_path, "parquet")
    write_df(right_df, right_path, "parquet")

    session = _browser_session(tmp_path)
    runtime = session.command_runtime
    sheet = session.viewer.sheet
    assert isinstance(sheet, FileBrowserSheet)
    left_row = _row_for_path(sheet, left_path)
    right_row = _row_for_path(sheet, right_path)
    session.viewer._selected_row_ids = {left_row, right_row}  # type: ignore[attr-defined]

    runtime.invoke("compare", source="test")

    diff_sheet = session.viewer.sheet
    assert diff_sheet is not sheet
    preview = diff_sheet.preview(10, ["global__kind"])
    kinds = list(preview.column("global__kind").values)
    allowed_kinds = {"changed", "left_only", "right_only", "unchanged"}
    assert kinds and all(kind in allowed_kinds for kind in kinds)


def test_compare_command_accepts_explicit_paths(tmp_path: Path) -> None:
    left_df = pl.DataFrame({"id": [1, 2], "value": [5, 6]})
    right_df = pl.DataFrame({"id": [1, 2], "value": [5, 11]})
    left_path = tmp_path / "explicit_left.parquet"
    right_path = tmp_path / "explicit_right.parquet"
    write_df(left_df, left_path, "parquet")
    write_df(right_df, right_path, "parquet")

    session = _browser_session(tmp_path)
    runtime = session.command_runtime
    result = runtime.invoke("compare", args=[str(left_path), str(right_path)], source="test")

    assert result.dispatch is not None
    assert result.dispatch.spec.name == "compare"
    diff_sheet = session.viewer.sheet
    preview = diff_sheet.preview(10, ["global__kind"])
    kinds = list(preview.column("global__kind").values)
    assert kinds and "changed" in kinds


def test_compare_command_requires_two_targets(tmp_path: Path) -> None:
    left_df = pl.DataFrame({"id": [1, 2], "value": [1, 2]})
    left_path = tmp_path / "single.parquet"
    write_df(left_df, left_path, "parquet")

    session = _browser_session(tmp_path)
    runtime = session.command_runtime
    sheet = session.viewer.sheet
    assert isinstance(sheet, FileBrowserSheet)
    left_row = _row_for_path(sheet, left_path)
    session.viewer._selected_row_ids = {left_row}  # type: ignore[attr-defined]

    runtime.invoke("compare", source="test")

    assert session.viewer.sheet is sheet
    assert "select exactly two" in (session.viewer.status_message or "")
    assert session.viewer.status_severity == "error"
