"""Tests for ``roam agents-md`` (R15)."""

from __future__ import annotations

import ast
import sys
from pathlib import Path

import pytest

sys.path.insert(0, str(Path(__file__).parent))
from conftest import (
    assert_json_envelope,
    git_init,
    index_in_process,
    invoke_cli,
    parse_json_output,
)

from tests._helpers.repo_root import repo_root as _repo_root

# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def cli_runner():
    from click.testing import CliRunner

    return CliRunner()


@pytest.fixture
def small_project(tmp_path, monkeypatch):
    """Tiny Python project with a class, a function, and a test file."""
    proj = tmp_path / "repo"
    proj.mkdir()
    (proj / ".gitignore").write_text(".roam/\n")

    (proj / "service.py").write_text(
        "class UserService:\n"
        "    def get_user(self, user_id):\n"
        "        return {'id': user_id}\n"
        "\n"
        "def create_user(name):\n"
        "    return UserService().get_user(name)\n"
    )
    (proj / "utils.py").write_text(
        "def format_name(first, last):\n"
        "    return f'{first} {last}'\n"
        "\n"
        "def compute_hash(payload):\n"
        "    return hash(payload)\n"
    )

    tests_dir = proj / "tests"
    tests_dir.mkdir()
    (tests_dir / "test_service.py").write_text(
        "from service import UserService\n\ndef test_get_user():\n    assert UserService().get_user(1) == {'id': 1}\n"
    )

    git_init(proj)
    monkeypatch.chdir(proj)
    out, rc = index_in_process(proj, "--force")
    assert rc == 0, f"index failed: {out}"
    return proj


# ---------------------------------------------------------------------------
# Generator-level tests
# ---------------------------------------------------------------------------


def test_generator_returns_structured_data(small_project):
    """``generate_agents_md`` populates the core sections from a real index."""
    from roam.agents_md.generator import generate_agents_md
    from roam.db.connection import open_db

    with open_db(readonly=True, project_root=small_project) as conn:
        am = generate_agents_md(small_project, conn)

    assert am.title == "AGENTS.md"
    assert am.generated_at, "should record a timestamp"
    assert am.summary, "should include a summary paragraph"
    assert am.stack, "stack section should be populated for an indexed project"
    # Conventions detector should return at least the function kind on a
    # project that has functions; we don't assert specific percentages
    # because the test fixture is tiny.
    assert isinstance(am.conventions, dict)
    assert "Quick read" in am.section_names()
    assert "Where to look next" in am.section_names()
    # Sources map records provenance per section. Stack always sourced.
    assert "stack" in am.sources


def test_generator_handles_missing_constitution_gracefully(small_project):
    """No ``.roam/constitution.yml`` -> falls back to defaults, never raises."""
    from roam.agents_md.generator import generate_agents_md
    from roam.db.connection import open_db

    # Sanity: no constitution file in the fixture.
    assert not (small_project / ".roam" / "constitution.yml").exists()

    with open_db(readonly=True, project_root=small_project) as conn:
        am = generate_agents_md(small_project, conn)

    # constitution_path is None when no file exists; defaults still fill gates.
    assert am.constitution_path is None
    assert am.pre_edit_gates, "should fall back to constitution-loader defaults"
    assert any("preflight" in g for g in am.pre_edit_gates)


def test_generator_handles_missing_laws_gracefully(small_project):
    """``--no-laws`` toggle yields an empty laws section without crashing."""
    from roam.agents_md.generator import generate_agents_md
    from roam.db.connection import open_db

    with open_db(readonly=True, project_root=small_project) as conn:
        am = generate_agents_md(small_project, conn, with_laws=False)

    assert am.laws == []
    assert "laws" not in am.sources


def test_render_markdown_returns_well_formed_markdown(small_project):
    """Rendered output uses GFM headings + tables and never emits emojis."""
    from roam.agents_md.generator import generate_agents_md, render_agents_markdown, render_markdown
    from roam.db.connection import open_db

    with open_db(readonly=True, project_root=small_project) as conn:
        am = generate_agents_md(small_project, conn)

    assert render_markdown is render_agents_markdown
    md = render_agents_markdown(am)
    assert md.startswith("# AGENTS.md")
    assert "## Quick read" in md
    assert "## Stack" in md
    # GFM tables for conventions section: every conventions row produces
    # at least one `|` line.
    if am.conventions.get("by_kind"):
        assert "| Kind |" in md
    # Plain ASCII only -- no curly quotes or emojis.
    for forbidden in ("—", "‘", "’", "“", "”"):
        assert forbidden not in md, f"non-ASCII char {forbidden!r} leaked into output"


# ---------------------------------------------------------------------------
# CLI-level tests
# ---------------------------------------------------------------------------


def test_cli_writes_to_stdout_by_default(cli_runner, small_project):
    """`roam agents-md` (no flags) emits Markdown directly to stdout."""
    result = invoke_cli(cli_runner, ["agents-md"], cwd=small_project)
    assert result.exit_code == 0, result.output
    assert "# AGENTS.md" in result.output
    assert "## Quick read" in result.output


def test_cli_with_out_flag_writes_file(cli_runner, small_project):
    """`roam agents-md --out path` persists the rendered Markdown."""
    target = small_project / "AGENTS_out.md"
    result = invoke_cli(cli_runner, ["agents-md", "--out", str(target)], cwd=small_project)
    assert result.exit_code == 0, result.output
    assert target.exists(), "target file should have been created"
    written = target.read_text(encoding="utf-8")
    assert written.startswith("# AGENTS.md")
    # The verdict line surfaces the write target.
    assert str(target) in result.output or "AGENTS_out.md" in result.output


def test_cli_json_envelope_includes_sources_consulted(cli_runner, small_project):
    """`--json` returns a properly-shaped envelope with provenance."""
    result = invoke_cli(cli_runner, ["agents-md"], cwd=small_project, json_mode=True)
    data = parse_json_output(result, command="agents-md")
    assert_json_envelope(data, command="agents-md")

    summary = data.get("summary", {})
    assert summary.get("state") == "ok"
    assert summary.get("partial_success") is False
    assert isinstance(summary.get("sections"), list)
    assert summary.get("section_count", 0) > 0

    # Envelope-level extras.
    assert "sources_consulted" in data
    assert isinstance(data["sources_consulted"], list)
    assert "stack" in data["sources_consulted"]
    assert "preview" in data
    assert isinstance(data["preview"], str)
    assert data["preview"].startswith("# AGENTS.md")
    assert "agents_md" in data
    assert "stack" in data["agents_md"]


def test_cli_refresh_flag_writes_agents_md(cli_runner, small_project):
    """`--refresh` is an alias for `--out AGENTS.md`."""
    target = small_project / "AGENTS.md"
    assert not target.exists()
    result = invoke_cli(cli_runner, ["agents-md", "--refresh"], cwd=small_project)
    assert result.exit_code == 0, result.output
    assert target.exists()
    assert "# AGENTS.md" in target.read_text(encoding="utf-8")


def test_cli_no_laws_no_rules_runs_cleanly(cli_runner, small_project):
    """Disabling optional sources still yields a valid envelope."""
    result = invoke_cli(
        cli_runner,
        ["agents-md", "--no-laws", "--no-rules"],
        cwd=small_project,
        json_mode=True,
    )
    data = parse_json_output(result, command="agents-md")
    assert_json_envelope(data, command="agents-md")
    # Laws / rules absent from sources_consulted when toggled off.
    assert "laws" not in data["sources_consulted"]
    assert "rules" not in data["sources_consulted"]


def test_cli_project_root_fallback_only_catches_oserror():
    """The root fallback must not swallow programmer-class exceptions."""
    source_path = _repo_root() / "src" / "roam" / "commands" / "cmd_agents_md.py"
    tree = ast.parse(source_path.read_text(encoding="utf-8"))

    handler_names = []
    for node in ast.walk(tree):
        if not isinstance(node, ast.Try):
            continue
        calls_find_project_root = any(
            isinstance(child, ast.Call) and isinstance(child.func, ast.Name) and child.func.id == "find_project_root"
            for stmt in node.body
            for child in ast.walk(stmt)
        )
        if calls_find_project_root:
            for handler in node.handlers:
                if isinstance(handler.type, ast.Name):
                    handler_names.append(handler.type.id)
                elif handler.type is None:
                    handler_names.append("<bare>")
                else:
                    handler_names.append(ast.unparse(handler.type))

    assert handler_names == ["OSError"]


@pytest.mark.xfail(
    reason="W11 live-smoke test against roam-code repo — failing on CI env JSON-parse; tracked, generator behavior is covered by the fixture-based tests above (deferred fix)"
)
def test_agents_md_smoke_on_roam_code(cli_runner):
    """End-to-end smoke: run against the actual roam-code repo.

    This is the W11 polish "never N/A without running it" rule -- a
    fixture-only test can pass while the real repo crashes. Asserts
    only structural facts so it survives normal codebase churn.
    """
    repo_root = _repo_root()
    # Skip if the index isn't built (e.g. a fresh CI worker that
    # hasn't run `roam index`); we don't want CI to fail on missing
    # index, only on a regression in the generator itself.
    if not (repo_root / ".roam" / "index.db").exists():
        pytest.skip("roam-code index not present; skipping live smoke test")

    result = invoke_cli(cli_runner, ["agents-md"], cwd=repo_root, json_mode=True)
    assert result.exit_code == 0, result.output
    # Skip if stdout is empty (degraded environment, not a regression — the
    # fixture-based tests above cover the generator behavior).
    if not (result.stdout or result.output or "").strip():
        pytest.skip("agents-md returned empty stdout (degraded env, not a regression)")
    data = parse_json_output(result, command="agents-md")
    assert_json_envelope(data, command="agents-md")
    sections = data["summary"]["sections"]
    # The big three signals should always show up on a real Python repo.
    assert "Stack" in sections
    assert "Naming conventions" in sections
    assert "Capability roster" in sections
