from __future__ import annotations

from pathlib import Path

import pytest

from iac.cli.formatting import emit_structured
from iac.runbook.discovery import book_id_for_path, resolve_book, split_book_step
from iac.runbook.discovery import (
    list_modules,
    parse_module_dirs,
    parse_script_dirs,
    resolve_module_file,
    resolve_script,
    split_module_function,
)
from iac.runbook.expr import (
    WhenContext,
    WhenEvaluator,
    _fn_contains,
    _fn_ends_with,
    _fn_get,
    _fn_has,
    _fn_starts_with,
    _sanitize,
)
from iac.runbook.loader import load_book, parse_vars_kv
from iac.runbook.sensitive import SecretRedactor
from iac.runbook.template import TemplateEngine, _parse_filter_call, _split_args


def test_emit_structured_all_paths(capsys: pytest.CaptureFixture[str]) -> None:
    emit_structured({"a": 1}, "json")
    out = capsys.readouterr().out
    assert '"a": 1' in out

    emit_structured({"a": 1}, "yaml")
    out = capsys.readouterr().out
    assert "a: 1" in out

    with pytest.raises(ValueError):
        emit_structured({"a": 1}, "table")  # type: ignore[arg-type]


def test_discovery_edge_paths(tmp_path: Path) -> None:
    f = tmp_path / "custom.book"
    f.write_text("x", encoding="utf-8")
    assert book_id_for_path(f) == "custom"

    direct = tmp_path / "direct.yaml"
    direct.write_text(
        "version: 1\nsteps: [{name: s, shell: 'echo hi'}]\n", encoding="utf-8"
    )
    assert resolve_book(str(direct), []) == direct.resolve()

    d = tmp_path / "d"
    d.mkdir()
    (d / "book.yaml").write_text(
        "version: 1\nsteps: [{name: s, shell: 'echo hi'}]\n", encoding="utf-8"
    )
    assert resolve_book(str(d), []) == (d / "book.yaml").resolve()
    (d / "book.yaml").unlink()
    with pytest.raises(FileNotFoundError):
        resolve_book(str(d), [])

    with pytest.raises(FileNotFoundError):
        resolve_book("missing", [tmp_path])

    with pytest.raises(ValueError):
        split_book_step("book@")


def test_loader_edge_paths(tmp_path: Path) -> None:
    bad = tmp_path / "bad.yaml"
    bad.write_text("- 1\n- 2\n", encoding="utf-8")
    with pytest.raises(ValueError):
        load_book(bad)

    with pytest.raises(ValueError):
        parse_vars_kv([" =x"])


def test_sensitive_edge_paths() -> None:
    red = SecretRedactor.from_values(["", "abc", "abc", "x"])
    assert red.redact_text("abc x") == "******** ********"
    # direct constructor to exercise empty-byte guard
    custom = SecretRedactor(_text_values=["x"], _byte_values=[b"", b"x"])
    assert custom.redact_bytes(b"x") == b"********"


def test_expr_edge_paths() -> None:
    wc = WhenContext(vars={"x": 1}, env={}, ctx={})
    ev = WhenEvaluator()
    # non-bool result path uses truthiness
    assert ev.eval("1", wc) is True

    class O:
        pass

    sanitized = _sanitize({"k": [1, (2, O())]})
    assert isinstance(sanitized["k"][1][1], str)

    assert _fn_has({}, "k") is False
    assert _fn_has({"k": 1}, "k") is True
    assert _fn_has([], "k") is False
    assert _fn_get([], "k", "d") == "d"
    assert _fn_get({"k": 2}, "k", "d") == 2
    assert _fn_contains("abc", "a") is True
    assert _fn_starts_with("abc", "a") is True
    assert _fn_ends_with("abc", "c") is True


def test_template_edge_paths() -> None:
    t = TemplateEngine.default()
    # non-container passthrough branch
    assert t.render_obj(42, vars_map={}, env_map={}) == 42
    # invalid base identifier branch
    assert t.render_str("{{ 1bad }}", vars_map={}, env_map={}) == ""
    # unknown filter branch
    assert (
        t.render_str("{{ name | missing }}", vars_map={"name": "x"}, env_map={}) == ""
    )

    # filter parser invalid call
    assert _parse_filter_call("@@") == ("", [])
    # bare arg path
    assert _parse_filter_call("replace(a,b)") == ("replace", ["a", "b"])
    # arg splitter coverage
    assert _split_args("'a',b,\"c\"") == ["'a'", "b", '"c"']


def test_script_discovery_and_resolution(tmp_path: Path) -> None:
    d1 = tmp_path / "scripts1"
    d2 = tmp_path / "scripts2"
    d1.mkdir()
    d2.mkdir()
    (d1 / "hello.sh").write_text("echo hi\n", encoding="utf-8")
    (d2 / "run").write_text("echo run\n", encoding="utf-8")

    dirs = parse_script_dirs(f"{d1}:{d2}:{d1}")
    assert any(p == d1.resolve() for p in dirs)
    assert any(p == d2.resolve() for p in dirs)

    assert resolve_script("hello", [d1]).name == "hello.sh"
    assert resolve_script("run", [d2]).name == "run"
    assert resolve_script(str(d1 / "hello.sh"), []).name == "hello.sh"

    book = tmp_path / "books" / "b.yaml"
    book.parent.mkdir(parents=True, exist_ok=True)
    book.write_text(
        "version: 1\nsteps: [{name: s, shell: 'echo hi'}]\n", encoding="utf-8"
    )
    local_script = book.parent / "local.sh"
    local_script.write_text("echo local\n", encoding="utf-8")
    assert resolve_script("local.sh", [], book_path=book) == local_script.resolve()

    with pytest.raises(FileNotFoundError):
        resolve_script("missing-script", [d1], book_path=book)


def test_module_discovery_and_resolution(tmp_path: Path) -> None:
    d1 = tmp_path / "modules1"
    d2 = tmp_path / "modules2"
    d1.mkdir()
    d2.mkdir()
    (d1 / "ops.py").write_text(
        "def run(ctx):\n    '''Run step.'''\n    return {}\n\ndef _private(ctx):\n    return {}\n",
        encoding="utf-8",
    )
    (d1 / "dist.py").write_text("def x(ctx):\n  return {}\n", encoding="utf-8")
    (d1 / "bad.py").write_text("def nope(:\n", encoding="utf-8")
    (d1 / "__init__.py").write_text("", encoding="utf-8")
    (d1 / "README.txt").write_text("ignore", encoding="utf-8")
    pkg = d2 / "cloud"
    pkg.mkdir()
    (pkg / "__init__.py").write_text("", encoding="utf-8")
    (pkg / "deploy.py").write_text(
        "def apply(ctx):\n    '''Apply resources.'''\n    return {}\n", encoding="utf-8"
    )

    dirs = parse_module_dirs(f"{d1}:{d2}:{d1}")
    assert any(p == d1.resolve() for p in dirs)
    assert any(p == d2.resolve() for p in dirs)

    m1 = resolve_module_file("ops", [d1])
    assert m1.name == "ops.py"
    m2 = resolve_module_file("cloud.deploy", [d2])
    assert m2.name == "deploy.py"
    assert resolve_module_file("cloud", [d2]).name == "__init__.py"
    with pytest.raises(FileNotFoundError):
        resolve_module_file("missing", [d1, d2])
    with pytest.raises(FileNotFoundError):
        resolve_module_file(".", [d1, d2])

    assert split_module_function("ops@run") == ("ops", "run")
    with pytest.raises(ValueError):
        split_module_function("ops")
    with pytest.raises(ValueError):
        split_module_function("@run")

    mods = list_modules([d1, d2])
    names = {m.module for m in mods}
    assert "ops" in names
    assert "cloud.deploy" in names
    assert "dist" not in names
    ops = next(m for m in mods if m.module == "ops")
    assert [f.name for f in ops.functions] == ["run"]
    bad = next(m for m in mods if m.module == "bad")
    assert bad.error


def test_list_modules_walk_edge_branches(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    from iac.runbook import discovery as dsc

    d = tmp_path / "mods"
    d.mkdir()
    (d / "ok.py").write_text("def run(ctx):\n  return {}\n", encoding="utf-8")
    (d / "dir.py").mkdir()
    outside = tmp_path / "outside"
    outside.mkdir()
    (outside / "x.py").write_text("def x(ctx):\n  return {}\n", encoding="utf-8")

    root = str(d.resolve())
    outside_root = str(outside.resolve())
    orig_is_file = dsc.Path.is_file

    def fake_walk(_path):
        # non-py skip, non-file skip (empty filename -> directory path), relative_to exception path,
        # and empty relative parts path.
        yield (root, ["sub"], ["note.txt", "", "dir.py"])
        yield (outside_root, [], ["x.py"])
        yield (root, [], ["ok.py"])

    def fake_is_file(p: Path):
        # Force the root path to behave like a file so rel.parts == () branch is covered.
        if str(p) == root:
            return True
        return orig_is_file(p)

    monkeypatch.setattr(dsc.os, "walk", fake_walk)
    monkeypatch.setattr(dsc.Path, "is_file", fake_is_file)
    mods = dsc.list_modules([d])
    assert any(m.module == "ok" for m in mods)
