"""Tests for ``mgf-common init-project`` subcommand."""

from __future__ import annotations

import argparse
import io
import json
from contextlib import redirect_stderr, redirect_stdout
from typing import TYPE_CHECKING

import pytest

from mgf.fed.cli._init_project import (
    AGENTS_MD_CONTENT,
    _render_claude_md,
    _render_claude_settings,
    _render_pyproject_snippet,
    add_init_project_args,
    run_init_project,
)

if TYPE_CHECKING:
    from pathlib import Path


def _make_args(
    *,
    name: str = "demo",
    kind: str = "cli",
    path: Path | None = None,
    force: bool = False,
    dry_run: bool = False,
) -> argparse.Namespace:
    parser = argparse.ArgumentParser()
    add_init_project_args(parser)
    argv = ["--name", name, "--kind", kind]
    if path is not None:
        argv.extend(["--path", str(path)])
    if force:
        argv.append("--force")
    if dry_run:
        argv.append("--dry-run")
    return parser.parse_args(argv)


class TestClaudeMdRender:
    def test_includes_project_name(self) -> None:
        content = _render_claude_md("demo-app", "cli")
        assert "# demo-app — Claude Reference" in content
        assert "Project kind:** cli" in content

    @pytest.mark.parametrize("kind", ["cli", "fastapi", "django", "gui", "library", "service"])
    def test_all_kinds_render(self, kind: str) -> None:
        content = _render_claude_md("demo-app", kind)
        # All kinds should produce a non-empty doc with the
        # standing-instructions block and the rule index.
        assert "Standing instructions" in content
        assert "DP-01" in content  # Architecture rules
        assert "EH-01" in content  # Error handling rules
        assert "LG-01" in content  # Logging rules
        # Quick start block has the project name interpolated.
        assert "demo-app" in content

    def test_feedback_section_present(self) -> None:
        # v0.10.0 — CLAUDE.md template tells the new project's
        # Claude session about the FEEDBACK.md submission flow,
        # so feedback gets routed to the canonical channel
        # without manual prompting.
        # v2.8 (2026-05-18) — section renamed to "Federation-
        # discipline reminders" and split into three disciplines
        # (feedback / adoption / conformance) with explicit
        # BEGIN/END markers per discipline. Existing assertions
        # still hold; new assertions pin the v2.8 shape.
        content = _render_claude_md("demo-app", "cli")
        assert "Federation-discipline reminders" in content
        assert "Feedback discipline" in content
        assert "mgf-fed feedback-howto" in content
        # Severity guide should be present so Claude can self-classify.
        for emoji in ("🔴", "🟡", "🟢", "💭"):
            assert emoji in content

    def test_v28_three_discipline_blocks_present(self) -> None:
        # v2.8 — the catch-up auto-block carries THREE BEGIN/END
        # sections (one per discipline) so each can evolve
        # independently in future catch-up runs. Pin all six
        # markers exist.
        content = _render_claude_md("demo-app", "cli")
        markers = [
            "<!-- BEGIN mgf-common: feedback-discipline -->",
            "<!-- END mgf-common: feedback-discipline -->",
            "<!-- BEGIN mgf-common: adoption-discipline -->",
            "<!-- END mgf-common: adoption-discipline -->",
            "<!-- BEGIN mgf-common: conformance-discipline -->",
            "<!-- END mgf-common: conformance-discipline -->",
        ]
        for m in markers:
            assert m in content, f"missing v2.8 discipline marker: {m}"
        # The three discipline section titles
        assert "Adoption-tracker discipline" in content
        assert "Conformance-tracker discipline" in content
        # The rule cross-references
        assert "DOC-13" in content
        assert "DOC-14" in content
        assert "DOC-15" in content
        # Template paths for adoption + conformance trackers
        assert "mgf-adoption-md-template.md" in content
        assert "mgf-standards-conformance-md-template.md" in content

    def test_top_30_rules_inlined(self) -> None:
        # Sanity: the inline rule block has roughly 30 rules.
        # Counting MUST appearances in the rule sections is a rough
        # but stable proxy.
        content = _render_claude_md("demo-app", "cli")
        # The inline section says "top-30 most-violated rules".
        assert "top-30" in content
        # Specific high-priority rules MUST be present.
        for rule_id in (
            "DP-01",
            "DP-04",
            "DP-09",
            "DP-12",
            "EH-01",
            "EH-03",
            "EH-11",
            "LG-01",
            "LG-04",
            "LG-08",
            "CF-01",
            "CF-05",
            "SC-01",
            "SC-06",
            "TS-01",
            "TS-04",
            "AP-01",
            "AP-04",
        ):
            assert rule_id in content, f"rule {rule_id} missing from CLAUDE.md template"


class TestClaudeSettingsRender:
    def test_default_kind_has_safe_permissions(self) -> None:
        content = _render_claude_settings("cli")
        payload = json.loads(content)
        assert "Bash(rm -rf *)" in payload["permissions"]["deny"]
        assert "Bash(git push --force *)" in payload["permissions"]["deny"]
        # Standard tooling allowed.
        assert any("ruff" in rule for rule in payload["permissions"]["allow"])
        assert any("pytest" in rule for rule in payload["permissions"]["allow"])

    def test_django_kind_includes_manage_py(self) -> None:
        content = _render_claude_settings("django")
        payload = json.loads(content)
        assert any("manage.py" in rule for rule in payload["permissions"]["allow"]), (
            "Django kind should allow `python manage.py *`"
        )

    def test_fastapi_kind_includes_uvicorn(self) -> None:
        content = _render_claude_settings("fastapi")
        payload = json.loads(content)
        assert any("uvicorn" in rule for rule in payload["permissions"]["allow"])


class TestPyprojectSnippet:
    def test_includes_four_gates(self) -> None:
        snippet = _render_pyproject_snippet("demo", "cli")
        assert "[tool.ruff]" in snippet
        assert "[tool.mypy]" in snippet
        assert "[tool.pytest.ini_options]" in snippet
        assert "[tool.importlinter]" in snippet
        # Strict mypy + filterwarnings = error are non-negotiable.
        assert "strict = true" in snippet
        assert 'filterwarnings = ["error"]' in snippet

    def test_pin_includes_siblings_for_kind(self) -> None:
        # FastAPI-shape projects pull mgf-common + mgf-fastapi[sqlalchemy] +
        # mgf-sqlalchemy + mgf-alembic + mgf-http (the framework + http +
        # DB siblings). Post-v0.28/0.29/0.30/0.31 extractions: each sibling
        # pinned independently and every framework adapter ships as a
        # sibling (mgf-common is the federation leaf with pure-Python
        # infrastructure only).
        # PAPER-52: the mgf-common pin is now templated from
        # mgf.common.__version__ rather than a hardcoded MINOR. The
        # assertion below pins the SHAPE (`mgf-common>=X.Y,<X.<Y+1>`)
        # without locking the value, so the test survives every release.
        from mgf.fed.cli._version_pin import current_pin_window

        live_pin = f"mgf-common{current_pin_window()}"
        snippet = _render_pyproject_snippet("demo", "fastapi")
        assert live_pin in snippet
        assert "mgf-fastapi[sqlalchemy]>=0.2" in snippet
        assert "mgf-sqlalchemy>=0.1" in snippet
        assert "mgf-alembic>=0.1" in snippet
        assert "mgf-http>=0.1" in snippet
        # The legacy ``mgf-common[fastapi,http,db,alembic,django]`` shapes
        # MUST NOT appear — every extra was extracted to siblings.
        assert "mgf-common[fastapi" not in snippet
        assert "mgf-common[http" not in snippet
        assert "mgf-common[db" not in snippet
        assert "mgf-common[alembic" not in snippet
        assert "mgf-common[django" not in snippet
        # Django-shape: mgf-common + mgf-django (no fastapi/sqlalchemy).
        snippet = _render_pyproject_snippet("demo", "django")
        assert live_pin in snippet
        assert "mgf-django>=0.1" in snippet
        assert "mgf-fastapi" not in snippet
        assert "mgf-sqlalchemy" not in snippet
        assert "mgf-common[django" not in snippet
        # Service-shape: just mgf-common + mgf-http (no fastapi).
        snippet = _render_pyproject_snippet("demo", "service")
        assert live_pin in snippet
        assert "mgf-http>=0.1" in snippet
        assert "mgf-fastapi" not in snippet
        assert "mgf-sqlalchemy" not in snippet
        # Library shape: no siblings.
        snippet = _render_pyproject_snippet("demo", "library")
        assert live_pin in snippet
        assert "mgf-fastapi" not in snippet
        assert "mgf-http" not in snippet
        assert "mgf-sqlalchemy" not in snippet
        assert "mgf-alembic" not in snippet
        assert "mgf-django" not in snippet


class TestRunDryRun:
    def test_dry_run_writes_nothing(self, tmp_path: Path) -> None:
        buf = io.StringIO()
        with redirect_stdout(buf):
            exit_code = run_init_project(
                _make_args(name="demo", kind="cli", path=tmp_path, dry_run=True),
            )
        assert exit_code == 0
        # Nothing on disk (PAPER-74: scaffold now creates the
        # canonical docs/claude/CLAUDE.md path; legacy docs/CLAUDE.md
        # also absent).
        assert not (tmp_path / "docs" / "claude" / "CLAUDE.md").exists()
        assert not (tmp_path / "docs" / "CLAUDE.md").exists()
        assert not (tmp_path / ".claude" / "settings.json").exists()
        # But output mentions both files + the pyproject snippet.
        out = buf.getvalue()
        assert "Would create" in out
        assert "CLAUDE.md" in out
        assert "settings.json" in out
        assert "pyproject.toml" in out


class TestRunActualWrite:
    def test_writes_scaffold_files(self, tmp_path: Path) -> None:
        buf = io.StringIO()
        with redirect_stdout(buf):
            exit_code = run_init_project(
                _make_args(name="demo", kind="library", path=tmp_path),
            )
        assert exit_code == 0
        # PAPER-74 (2026-05-26): scaffold now writes the canonical
        # DOC-07 path `docs/claude/CLAUDE.md` (was legacy
        # `docs/CLAUDE.md` pre-PAPER-74).
        claude_path = tmp_path / "docs" / "claude" / "CLAUDE.md"
        settings_path = tmp_path / ".claude" / "settings.json"
        agents_path = tmp_path / "AGENTS.md"
        assert claude_path.is_file()
        # Legacy path NOT created.
        assert not (tmp_path / "docs" / "CLAUDE.md").exists()
        assert settings_path.is_file()
        # AGENTS.md — the cross-tool agent-discovery pointer.
        assert agents_path.is_file()
        assert agents_path.read_text(encoding="utf-8") == AGENTS_MD_CONTENT
        # Content has the project name.
        assert "demo — Claude Reference" in claude_path.read_text()
        # Settings is valid JSON.
        json.loads(settings_path.read_text())

    def test_refuses_overwrite_without_force(self, tmp_path: Path) -> None:
        # First run creates the files.
        buf = io.StringIO()
        with redirect_stdout(buf):
            run_init_project(
                _make_args(name="demo", kind="cli", path=tmp_path),
            )
        # Second run without --force should fail.
        out_buf = io.StringIO()
        err_buf = io.StringIO()
        with redirect_stdout(out_buf), redirect_stderr(err_buf):
            exit_code = run_init_project(
                _make_args(name="demo", kind="cli", path=tmp_path),
            )
        assert exit_code == 1
        assert "refusing to overwrite" in err_buf.getvalue()

    def test_force_overwrites(self, tmp_path: Path) -> None:
        buf = io.StringIO()
        with redirect_stdout(buf):
            run_init_project(
                _make_args(name="demo", kind="cli", path=tmp_path),
            )
            run_init_project(
                _make_args(name="renamed", kind="cli", path=tmp_path, force=True),
            )
        # Second run renamed the project (PAPER-74 canonical path).
        content = (tmp_path / "docs" / "claude" / "CLAUDE.md").read_text()
        assert "renamed — Claude Reference" in content

    def test_invalid_target_dir(self) -> None:
        out_buf = io.StringIO()
        err_buf = io.StringIO()
        with redirect_stdout(out_buf), redirect_stderr(err_buf):
            from pathlib import Path

            exit_code = run_init_project(
                _make_args(
                    name="demo",
                    kind="cli",
                    path=Path("/nonexistent/path/v09"),
                ),
            )
        assert exit_code == 1
        assert "is not a directory" in err_buf.getvalue()

    def test_dry_run_works_against_nonexistent_path(self) -> None:
        # v0.11.1 fix: dry-run is a preview, no writes happen, so
        # it must work even when the target dir doesn't exist —
        # otherwise users can't preview before `mkdir + cd`.
        from pathlib import Path

        out_buf = io.StringIO()
        with redirect_stdout(out_buf):
            exit_code = run_init_project(
                _make_args(
                    name="demo",
                    kind="cli",
                    path=Path("/nonexistent/path/v0111"),
                    dry_run=True,
                ),
            )
        assert exit_code == 0
        # Output should still describe what would be created.
        assert "Would create" in out_buf.getvalue()
        assert "CLAUDE.md" in out_buf.getvalue()


# ---------------------------------------------------------------------------
# PAPER-21 regression guard — every kind template's quick-start block MUST NOT
# import from mgf.common's private surface. Closes the closed-box-leak that
# slipped into the fastapi-kind quick-start in v0.17.2.
# ---------------------------------------------------------------------------


class TestQuickStartBlocksNoClosedBoxLeaks:
    """Pin: rendered CLAUDE.md auto-blocks must satisfy the same
    closed-box-leak rule the catch-up audit enforces on consumer
    src/. The auto-block ships into every consumer's session-loaded
    reference doc; teaching `from mgf.common._x import …` there
    contradicts AP-14 + the audit row that fires on consumer
    code (PAPER-08). One-line regression guard: every kind's
    quick-start block, post-render, satisfies _is_private_module
    against ZERO of its imports.
    """

    @pytest.mark.parametrize("kind", ["cli", "fastapi", "django", "gui", "library", "service"])
    def test_no_private_mgf_imports_in_kind_template(self, kind: str) -> None:
        from mgf.fed.cli._catch_up import _is_private_module
        from mgf.fed.cli._init_project import _render_claude_md

        rendered = _render_claude_md("demo", kind)

        # The audit's predicate is name-shaped (`mgf.common._x`).
        # We grep the rendered text for `from mgf.common.<thing> import`
        # and run each module name through the predicate.
        import re

        for match in re.finditer(
            r"^\s*from\s+(mgf\.common[\w.]*)\s+import\s",
            rendered,
            re.MULTILINE,
        ):
            module = match.group(1)
            assert not _is_private_module(module), (
                f"kind={kind!r} template's quick-start imports private "
                f"module {module!r} — same closed-box-leak the catch-up "
                f"audit flags on consumer src/. Replace with the public "
                f"alternative in mgf.common.__all__. (closes PAPER-21)"
            )

        # Also catch `import mgf.common._x` direct private imports.
        for match in re.finditer(
            r"^\s*import\s+(mgf\.common[\w.]*)$",
            rendered,
            re.MULTILINE,
        ):
            module = match.group(1)
            assert not _is_private_module(module), (
                f"kind={kind!r} template imports private module "
                f"{module!r} directly. (closes PAPER-21)"
            )

    def test_paper_59_gui_kind_shipped(self) -> None:
        # PAPER-59 — desktop-GUI consumers had no matching --kind and
        # were forced to scaffold as 'service', inheriting a
        # wrong-shaped `serve()` async-loop example. Pin: gui IS a
        # valid kind, its quick-start uses an event-loop `app.run()`
        # rather than `asyncio.run(serve())`, and the deps list does
        # NOT pull in the http sibling (no HTTP shape).
        from mgf.fed.cli._init_project import (
            _KINDS,
            _QUICK_START_BLOCKS,
            _render_claude_md,
            _render_pyproject_snippet,
        )

        assert "gui" in _KINDS
        assert "gui" in _QUICK_START_BLOCKS

        rendered = _render_claude_md("hrb-app", "gui")
        # Must NOT carry the service `serve()` loop example.
        assert "async def serve" not in rendered
        assert "asyncio.run(serve())" not in rendered
        # Must carry a GUI event-loop shape.
        assert "app.run()" in rendered
        # Public bootstrap import (PAPER-21 closed-box-leak guard).
        assert "import mgf.common" in rendered
        assert "Project kind:** gui" in rendered

        # Pyproject snippet: mgf-common only, no http/fastapi siblings.
        snippet = _render_pyproject_snippet("hrb-app", "gui")
        assert "mgf-common" in snippet
        assert "mgf-http" not in snippet
        assert "mgf-fastapi" not in snippet
        assert "mgf-django" not in snippet
        assert "mgf-sqlalchemy" not in snippet

    def test_paper_21_regression_specific(self) -> None:
        # The exact leak PlasmaMapper filed: fastapi's quick-start
        # imported `from mgf.common._lifecycle import bootstrap`.
        # The right import is `from mgf.common import bootstrap`.
        from mgf.fed.cli._init_project import _render_claude_md

        rendered = _render_claude_md("plasma", "fastapi")
        assert "from mgf.common._lifecycle" not in rendered, (
            "PAPER-21 regression — fastapi-kind template re-introduced "
            "the private-import leak. The bootstrap public re-export "
            "lives in mgf.common.__init__.py; templates MUST use "
            "`from mgf.common import bootstrap`."
        )
        assert "from mgf.common import bootstrap" in rendered, (
            "fastapi-kind template missing the canonical public-import "
            "form: `from mgf.common import bootstrap`."
        )
