"""Tests for .opencode/scripts/project-status.py — architecture oracle.

All gh/git calls are mocked via monkeypatch on the module's ``run_cmd``
helper. Filesystem checks use ``tmp_path`` with ``monkeypatch`` on
``ps.REPO_ROOT`` so each test sees an isolated repo.

Covers: auto-detect (5 types + unknown), 8 check groups, format output
([OK]/[WARN] + Итог + Рекомендации), exit codes (issue #275: exit code is
always 0 — ``--check`` is accepted but no longer forces exit 1), ``--fast``
skip of branch protection.
"""

import importlib.util
import shutil
import sys
from pathlib import Path

import pytest

SCRIPT_PATH = Path(__file__).resolve().parent.parent / ".opencode" / "scripts" / "project-status.py"
spec = importlib.util.spec_from_file_location("project_status", SCRIPT_PATH)
ps = importlib.util.module_from_spec(spec)
sys.modules["project_status"] = ps
spec.loader.exec_module(ps)


GIT_REMOTE_MOCK: tuple[tuple[str, ...], tuple[int, str, str]] = (
    ("git", "remote"),
    (0, "https://github.com/slaid098/opencode-config.git\n", ""),
)


@pytest.fixture(autouse=True)
def _isolate_repo(monkeypatch, tmp_path):
    """Point ``ps.REPO_ROOT`` at ``tmp_path`` and reset CONFIG for each test.

    Also builds a ``RepoCtx`` available as ``ctx`` fixture (autouse-isolated)
    so check-functions get an explicit context instead of touching globals.
    """
    monkeypatch.setattr(ps, "REPO_ROOT", tmp_path)
    monkeypatch.setattr(ps, "CONFIG", dict(ps.DEFAULT_CONFIG))


@pytest.fixture
def ctx(tmp_path) -> ps.RepoCtx:
    """Fresh RepoCtx rooted at ``tmp_path`` with default config thresholds."""
    return ps.RepoCtx(root=tmp_path, config=dict(ps.DEFAULT_CONFIG))


def mock_run_cmd(responses: dict[tuple, tuple[int, str, str]]):
    """Factory: mock run_cmd matching by command prefix.

    Automatically includes a ``git remote get-url origin`` mock so
    ``get_repo_full_name()`` works without extra boilerplate.
    """
    merged = {GIT_REMOTE_MOCK[0]: GIT_REMOTE_MOCK[1], **responses}

    def _mock(args: list[str]) -> tuple[int, str, str]:
        for prefix, result in merged.items():
            if tuple(args[: len(prefix)]) == tuple(prefix):
                return result
        return (1, "", f"unmocked call: {args}")

    return _mock


def _tool_sections(
    has_ruff: bool,
    has_mypy: bool,
    has_pytest: bool,
    cov_source: list | None,
    cov_fail: str | None,
) -> list[str]:
    """Build the [tool.*] section lines for pyproject.toml."""
    lines: list[str] = []
    if has_ruff:
        lines.extend(["[tool.ruff]", 'target-version = "py312"', "line-length = 100", ""])
    if has_mypy:
        lines.extend(["[tool.mypy]", 'python_version = "3.12"', "strict = true", ""])
    if has_pytest:
        lines.append("[tool.pytest.ini_options]")
        addopts = "--cov=src --cov-report=term-missing --timeout=120"
        if cov_fail:
            addopts += f" --cov-fail-under={cov_fail}"
        lines.append(f'addopts = "{addopts}"')
        lines.extend(['asyncio_mode = "auto"', 'testpaths = ["tests"]', ""])
    if cov_source is not None:
        lines.append("[tool.coverage.run]")
        source_str = ", ".join(f'"{s}"' for s in cov_source)
        lines.append(f"source = [{source_str}]")
        lines.append("branch = true")
        lines.append("")
        lines.append("[tool.coverage.report]")
        lines.append("exclude_lines = [")
        lines.append('    "pragma: no cover",')
        lines.append('    "if __name__ == .__main__.:",')
        lines.append('    "if TYPE_CHECKING:",')
        lines.append("]")
        lines.append("")
    return lines


def _write_pyproject(
    tmp_path: Path,
    *,
    deps: list[str] | None = None,
    scripts: dict | None = None,
    has_ruff: bool = True,
    has_mypy: bool = True,
    has_pytest: bool = True,
    cov_source: list | None = None,
    cov_fail: str | None = None,
    name: str = "test-repo",
    description: str = "test repo for project-status",
    requires_python: str = ">=3.12",
    hatch_packages: list | None = None,
) -> None:
    """Write a minimal pyproject.toml with the requested [tool.*] sections."""
    norm_name = (
        ps._normalize_package_name(name)
        if hasattr(ps, "_normalize_package_name")
        else name.replace("-", "_")
    )
    lines = [
        "[build-system]",
        'requires = ["hatchling"]',
        'build-backend = "hatchling.build"',
        "",
        "[project]",
        f'name = "{name}"',
        'version = "0.1.0"',
        f'description = "{description}"',
        f'requires-python = "{requires_python}"',
        "",
        "dependencies = [",
    ]
    for d in deps or []:
        lines.append(f'    "{d}",')
    lines.extend(["]", "", "[project.optional-dependencies]", "dev = ["])
    if has_pytest:
        lines.append('    "pytest>=8.0",')
    if has_mypy:
        lines.append('    "mypy>=1.10",')
    if has_ruff:
        lines.append('    "ruff>=0.5",')
    lines.extend(["]", ""])
    if scripts:
        lines.append("[project.scripts]")
        for k, v in scripts.items():
            lines.append(f'{k} = "{v}"')
        lines.append("")
    # [tool.hatch.build.targets.wheel] packages — only emit if src/<pkg>/ exists
    # or caller explicitly passes hatch_packages.
    src_pkg = tmp_path / "src" / norm_name
    if hatch_packages is not None:
        lines.append("[tool.hatch.build.targets.wheel]")
        pkgs_str = ", ".join(f'"{p}"' for p in hatch_packages)
        lines.append(f"packages = [{pkgs_str}]")
        lines.append("")
    elif src_pkg.exists():
        lines.append("[tool.hatch.build.targets.wheel]")
        lines.append(f'packages = ["src/{norm_name}"]')
        lines.append("")
    lines.extend(_tool_sections(has_ruff, has_mypy, has_pytest, cov_source, cov_fail))
    (tmp_path / "pyproject.toml").write_text("\n".join(lines))


def _make_backend_repo(tmp_path: Path) -> None:
    """Create a minimal backend-type repo skeleton in tmp_path.

    Uses the nested ``src/<package>/api/v1`` layout (issue #241): the package
    name is ``test_repo`` (normalized from the default ``[project].name``
    ``test-repo``).
    """
    pkg = "test_repo"
    for rel in [
        f"src/{pkg}/api/v1",
        f"src/{pkg}/db/models",
        f"src/{pkg}/schemas",
        f"src/{pkg}/services",
        f"src/{pkg}/config",
        "tests",
    ]:
        (tmp_path / rel).mkdir(parents=True, exist_ok=True)
    (tmp_path / "src" / pkg / "__init__.py").write_text("")
    (tmp_path / f"src/{pkg}/config/settings.py").write_text("settings = {}\n")
    (tmp_path / "main.py").write_text(
        "from contextlib import asynccontextmanager\n"
        "@asynccontextmanager\nasync def lifespan(app): yield\n"
    )
    (tmp_path / f"src/{pkg}/api/v1/users.py").write_text(
        "from fastapi import APIRouter\nrouter = APIRouter()\n"
        "@router.get('/users')\nasync def list_users():\n    return []\n"
    )
    _write_pyproject(tmp_path, deps=["fastapi", "uvicorn"], cov_source=["src"], cov_fail="80")
    (tmp_path / "tests/conftest.py").write_text("import pytest\n")
    (tmp_path / "tests/test_users.py").write_text("def test_ok(): assert True\n")
    (tmp_path / "tests/unit").mkdir(parents=True, exist_ok=True)
    (tmp_path / "tests/unit/test_user_service.py").write_text(
        "def test_user_service(): assert True\n"
    )
    (tmp_path / "tests/api").mkdir(parents=True, exist_ok=True)
    (tmp_path / "tests/api/test_users.py").write_text("def test_users_api(): assert True\n")
    # Extras for check_pyproject (group 8) to pass:
    (tmp_path / ".python-version").write_text("3.12\n")
    (tmp_path / "uv.lock").write_text("# minimal lockfile stub\n")
    (tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n")


def _make_fullstack_repo(tmp_path: Path) -> None:
    """Create a minimal fullstack repo skeleton in tmp_path.

    Mirrors the cookiecutter fullstack template (issue #241 nested layout):
    backend ``pyproject.toml`` lives under ``backend/``, the package under
    ``backend/src/<package>/`` with ``api/v1`` nested. The package name is
    ``test_repo`` (normalized from the default ``[project].name``
    ``test-repo``). Frontend is a stub ``package.json``.
    """
    pkg = "test_repo"
    for rel in [
        f"backend/src/{pkg}/api/v1",
        f"backend/src/{pkg}/db/models",
        f"backend/src/{pkg}/schemas",
        f"backend/src/{pkg}/services",
        f"backend/src/{pkg}/config",
        "backend/tests",
        "frontend",
    ]:
        (tmp_path / rel).mkdir(parents=True, exist_ok=True)
    (tmp_path / "backend" / "src" / pkg / "__init__.py").write_text("")
    (tmp_path / "backend" / f"src/{pkg}/config/settings.py").write_text("settings = {}\n")
    (tmp_path / "backend" / "main.py").write_text(
        "from contextlib import asynccontextmanager\n"
        "@asynccontextmanager\nasync def lifespan(app): yield\n"
    )
    (tmp_path / "backend" / f"src/{pkg}/api/v1/users.py").write_text(
        "from fastapi import APIRouter\nrouter = APIRouter()\n"
        "@router.get('/users')\nasync def list_users():\n    return []\n"
    )
    _write_pyproject(tmp_path / "backend", deps=["fastapi", "uvicorn"], cov_source=["src"])
    # Fullstack cookiecutter template includes Tailwind v4 + shadcn-svelte + TS
    # (issue #266): package.json deps + components.json + tsconfig.json are the
    # 4 frontend stack markers checked by ``_check_frontend_stack``.
    # Issue #278: mobile-first markers (manifest, mobile.spec.ts, a11y.spec.ts,
    # app.html viewport+manifest, @axe-core/playwright) are also present so the
    # default fixture mirrors a freshly generated fullstack cookiecutter repo.
    (tmp_path / "frontend" / "package.json").write_text(
        '{"name": "test-frontend", "dependencies": {"tailwindcss": "^4.0.0", '
        '"bits-ui": "^1.0.0"}, "devDependencies": {"@axe-core/playwright": "^4.10.0"}}\n'
    )
    (tmp_path / "frontend" / "tsconfig.json").write_text('{"compilerOptions": {}}\n')
    (tmp_path / "frontend" / "components.json").write_text("{}\n")
    (tmp_path / "frontend" / "static").mkdir(parents=True, exist_ok=True)
    (tmp_path / "frontend" / "static" / "manifest.webmanifest").write_text("{}\n")
    (tmp_path / "frontend" / "static" / "icon.svg").write_text("<svg></svg>\n")
    (tmp_path / "frontend" / "src").mkdir(parents=True, exist_ok=True)
    (tmp_path / "frontend" / "src" / "app.html").write_text(
        '<!doctype html><html><head>'
        '<meta name="viewport" content="width=device-width, initial-scale=1" />'
        '<link rel="manifest" href="/manifest.webmanifest" />'
        '</head><body></body></html>\n'
    )
    (tmp_path / "frontend" / "tests" / "e2e").mkdir(parents=True, exist_ok=True)
    (tmp_path / "frontend" / "tests" / "e2e" / "mobile.spec.ts").write_text("// mobile\n")
    (tmp_path / "frontend" / "tests" / "e2e" / "accessibility.spec.ts").write_text(
        "// a11y\n"
    )


# ── parse_remote_url ─────────────────────────────────────────────────────────


def test_parse_remote_url_https():
    assert ps.parse_remote_url("https://github.com/slaid098/opencode-config.git") == (
        "github.com",
        "slaid098",
        "opencode-config",
    )


def test_parse_remote_url_ssh():
    assert ps.parse_remote_url("git@github.com:slaid098/opencode-config.git") == (
        "github.com",
        "slaid098",
        "opencode-config",
    )


def test_parse_remote_url_invalid():
    with pytest.raises(ValueError, match="Cannot parse remote URL"):
        ps.parse_remote_url("not-a-valid-url")


# ── get_repo_full_name ───────────────────────────────────────────────────────


def test_get_repo_full_name_ok(monkeypatch):
    monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({}))
    assert ps.get_repo_full_name() == "slaid098/opencode-config"


def test_get_repo_full_name_no_remote(monkeypatch):
    monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("git", "remote"): (1, "", "no remote")}))
    assert ps.get_repo_full_name() is None


def test_get_repo_full_name_bad_url(monkeypatch):
    monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("git", "remote"): (0, "not-a-url\n", "")}))
    assert ps.get_repo_full_name() is None


# ── load_config ──────────────────────────────────────────────────────────────


def test_load_config_defaults_when_no_pyproject(tmp_path):
    monkeypatch = pytest.MonkeyPatch()
    monkeypatch.setattr(ps, "REPO_ROOT", tmp_path)
    cfg = ps.load_config()
    assert cfg["route_line_limit"] == 50
    assert cfg["min_test_count"] == 1


def test_load_config_reads_section(tmp_path):
    monkeypatch = pytest.MonkeyPatch()
    monkeypatch.setattr(ps, "REPO_ROOT", tmp_path)
    (tmp_path / "pyproject.toml").write_text(
        "[tool.project-status]\nroute_line_limit = 80\nmin_test_count = 3\n"
    )
    cfg = ps.load_config()
    assert cfg["route_line_limit"] == 80
    assert cfg["min_test_count"] == 3
    assert cfg["require_branch_protection"] is False


def test_load_config_bad_toml_returns_defaults(tmp_path):
    monkeypatch = pytest.MonkeyPatch()
    monkeypatch.setattr(ps, "REPO_ROOT", tmp_path)
    (tmp_path / "pyproject.toml").write_text("not valid toml = = =")
    cfg = ps.load_config()
    assert cfg["route_line_limit"] == 50


# ── detect_project_type ──────────────────────────────────────────────────────


def test_detect_fullstack(tmp_path, ctx):
    (tmp_path / "backend").mkdir()
    (tmp_path / "frontend").mkdir()
    assert ps.detect_project_type(ctx) == ps.ProjectType.FULLSTACK


def test_detect_backend(tmp_path, ctx):
    _make_backend_repo(tmp_path)
    assert ps.detect_project_type(ctx) == ps.ProjectType.BACKEND


def test_detect_bot_via_file(tmp_path, ctx):
    (tmp_path / "src").mkdir()
    (tmp_path / "src/bot.py").write_text("from aiogram import Dispatcher\n")
    _write_pyproject(tmp_path, deps=[])
    assert ps.detect_project_type(ctx) == ps.ProjectType.BOT


def test_detect_bot_via_dep(tmp_path, ctx):
    _write_pyproject(tmp_path, deps=["aiogram"])
    assert ps.detect_project_type(ctx) == ps.ProjectType.BOT


def test_detect_worker_via_file(tmp_path, ctx):
    (tmp_path / "src").mkdir()
    (tmp_path / "src/flow.py").write_text("from prefect import flow\n")
    _write_pyproject(tmp_path, deps=[])
    assert ps.detect_project_type(ctx) == ps.ProjectType.WORKER


def test_detect_worker_via_dep(tmp_path, ctx):
    _write_pyproject(tmp_path, deps=["prefect"])
    assert ps.detect_project_type(ctx) == ps.ProjectType.WORKER


def test_detect_cli(tmp_path, ctx):
    (tmp_path / "src").mkdir()
    pkg = tmp_path / "src" / "mycli"
    pkg.mkdir()
    (pkg / "__init__.py").write_text("")
    _write_pyproject(tmp_path, deps=["typer"], scripts={"mycli": "mycli.cli:main"})
    assert ps.detect_project_type(ctx) == ps.ProjectType.CLI


def test_detect_unknown_empty_repo(tmp_path, ctx):
    _write_pyproject(tmp_path, deps=[])
    assert ps.detect_project_type(ctx) == ps.ProjectType.UNKNOWN


# ── check_structure ──────────────────────────────────────────────────────────


def test_check_structure_backend_ok(tmp_path, ctx):
    _make_backend_repo(tmp_path)
    group = ps.check_structure(ps.ProjectType.BACKEND, ctx)
    assert group.overall() == ps.CheckStatus.OK
    assert all(c.status == ps.CheckStatus.OK for c in group.checks)


def test_check_structure_backend_missing_dir(tmp_path, ctx):
    _write_pyproject(tmp_path, deps=["fastapi", "uvicorn"])
    (tmp_path / "main.py").write_text(
        "from contextlib import asynccontextmanager\n"
        "@asynccontextmanager\nasync def lifespan(app): yield\n"
    )
    group = ps.check_structure(ps.ProjectType.BACKEND, ctx)
    assert group.overall() == ps.CheckStatus.WARN
    assert any(
        c.name == "src/test_repo/api/v1" and c.status == ps.CheckStatus.WARN for c in group.checks
    )


def test_check_structure_backend_no_lifespan(tmp_path, ctx):
    pkg = "test_repo"
    for rel in [
        f"src/{pkg}/api/v1",
        f"src/{pkg}/db/models",
        f"src/{pkg}/schemas",
        f"src/{pkg}/services",
        f"src/{pkg}/config",
        "tests",
    ]:
        (tmp_path / rel).mkdir(parents=True, exist_ok=True)
    (tmp_path / "src" / pkg / "__init__.py").write_text("")
    (tmp_path / f"src/{pkg}/config/settings.py").write_text("settings = {}\n")
    (tmp_path / "main.py").write_text("app = None\n")
    _write_pyproject(tmp_path, deps=["fastapi", "uvicorn"])
    group = ps.check_structure(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "main.py lifespan" and c.status == ps.CheckStatus.WARN for c in group.checks
    )


def test_check_structure_unknown_warn(tmp_path, ctx):
    group = ps.check_structure(ps.ProjectType.UNKNOWN, ctx)
    assert group.overall() == ps.CheckStatus.WARN


def test_check_structure_cli_with_package(tmp_path, ctx):
    (tmp_path / "src").mkdir()
    pkg = tmp_path / "src" / "mycli"
    pkg.mkdir()
    (pkg / "__init__.py").write_text("")
    group = ps.check_structure(ps.ProjectType.CLI, ctx)
    assert any(c.status == ps.CheckStatus.OK and "package" in c.name for c in group.checks)


def test_check_structure_cli_no_package(tmp_path, ctx):
    (tmp_path / "src").mkdir()
    group = ps.check_structure(ps.ProjectType.CLI, ctx)
    assert any(c.status == ps.CheckStatus.WARN for c in group.checks)


# ── check_thin_routes ────────────────────────────────────────────────────────


def test_thin_routes_ok(tmp_path, ctx):
    _make_backend_repo(tmp_path)
    group = ps.check_thin_routes(ps.ProjectType.BACKEND, ctx)
    assert group.overall() == ps.CheckStatus.OK


def test_thin_routes_over_limit(tmp_path, ctx):
    pkg = "test_repo"
    for rel in [
        f"src/{pkg}/api/v1",
        f"src/{pkg}/db/models",
        f"src/{pkg}/schemas",
        f"src/{pkg}/services",
        f"src/{pkg}/config",
    ]:
        (tmp_path / rel).mkdir(parents=True, exist_ok=True)
    (tmp_path / "src" / pkg / "__init__.py").write_text("")
    (tmp_path / f"src/{pkg}/config/settings.py").write_text("settings = {}\n")
    (tmp_path / "main.py").write_text("app = None\n")
    _write_pyproject(tmp_path, deps=["fastapi", "uvicorn"])
    body = "\n    x = 1\n" * 60
    (tmp_path / f"src/{pkg}/api/v1/users.py").write_text(
        "from fastapi import APIRouter\nrouter = APIRouter()\n"
        "@router.get('/users')\nasync def list_users():"
        f"{body}    return []\n"
    )
    group = ps.check_thin_routes(ps.ProjectType.BACKEND, ctx)
    assert group.overall() == ps.CheckStatus.WARN
    assert any("превышение" in c.detail for c in group.checks)


def test_thin_routes_not_applicable_for_cli(tmp_path, ctx):
    group = ps.check_thin_routes(ps.ProjectType.CLI, ctx)
    assert group.overall() == ps.CheckStatus.OK


def test_thin_routes_no_api_dir(tmp_path, ctx):
    group = ps.check_thin_routes(ps.ProjectType.BACKEND, ctx)
    assert group.overall() == ps.CheckStatus.WARN


def test_thin_routes_import_src_db_models_warn(tmp_path, ctx):
    """Route импортирует ``src.db.models`` → WARN (issue #275: non-blocking)."""
    _make_backend_repo(tmp_path)
    (tmp_path / "src/test_repo/api/v1/users.py").write_text(
        "from fastapi import APIRouter\nfrom src.db.models import User\n"
        "router = APIRouter()\n"
        "@router.get('/users')\nasync def list_users():\n    return []\n"
    )
    group = ps.check_thin_routes(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "route imports" and c.status == ps.CheckStatus.WARN for c in group.checks
    ), f"expected WARN on route imports, got: {group.checks}"


def test_thin_routes_import_tortoise_warn(tmp_path, ctx):
    """Route импортирует ``tortoise`` → WARN (issue #275: non-blocking)."""
    _make_backend_repo(tmp_path)
    (tmp_path / "src/test_repo/api/v1/users.py").write_text(
        "from fastapi import APIRouter\nfrom tortoise import fields\n"
        "router = APIRouter()\n"
        "@router.get('/users')\nasync def list_users():\n    return []\n"
    )
    group = ps.check_thin_routes(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "route imports" and c.status == ps.CheckStatus.WARN for c in group.checks
    ), f"expected WARN on route imports, got: {group.checks}"


def test_thin_routes_import_submodule_of_src_db_models_warn(tmp_path, ctx):
    """``from src.db.models.user import User`` → WARN (submodule of forbidden)."""
    _make_backend_repo(tmp_path)
    (tmp_path / "src/test_repo/api/v1/users.py").write_text(
        "from fastapi import APIRouter\nfrom src.db.models.user import User\n"
        "router = APIRouter()\n"
        "@router.get('/users')\nasync def list_users():\n    return []\n"
    )
    group = ps.check_thin_routes(ps.ProjectType.BACKEND, ctx)
    assert any(c.name == "route imports" and c.status == ps.CheckStatus.WARN for c in group.checks)


def test_thin_routes_import_services_ok(tmp_path, ctx):
    """Route импортирует ``from <pkg>.services import user_service`` → OK."""
    _make_backend_repo(tmp_path)
    (tmp_path / "src/test_repo/api/v1/users.py").write_text(
        "from fastapi import APIRouter\nfrom test_repo.services import user_service\n"
        "router = APIRouter()\n"
        "@router.get('/users')\nasync def list_users():\n    return user_service.list_all()\n"
    )
    group = ps.check_thin_routes(ps.ProjectType.BACKEND, ctx)
    assert all(
        c.status != ps.CheckStatus.WARN or c.name != "route imports" for c in group.checks
    ), f"expected no WARN on route imports, got: {group.checks}"
    assert any(c.name == "route imports" and c.status == ps.CheckStatus.OK for c in group.checks)


def test_thin_routes_import_db_connection_ok(tmp_path, ctx):
    """``from <pkg>.db.connection import init_db`` → OK (connection, not model)."""
    _make_backend_repo(tmp_path)
    (tmp_path / "src/test_repo/api/v1/users.py").write_text(
        "from fastapi import APIRouter\nfrom test_repo.db.connection import init_db\n"
        "router = APIRouter()\n"
        "@router.get('/users')\nasync def list_users():\n    return []\n"
    )
    group = ps.check_thin_routes(ps.ProjectType.BACKEND, ctx)
    assert all(
        c.status != ps.CheckStatus.WARN or c.name != "route imports" for c in group.checks
    ), f"db.connection is allowed (not a model), got: {group.checks}"


def test_thin_routes_import_ok_no_fail_on_over_limit(tmp_path, ctx):
    """OK imports + over-limit line count → WARN only (no FAIL)."""
    pkg = "test_repo"
    for rel in [
        f"src/{pkg}/api/v1",
        f"src/{pkg}/db/models",
        f"src/{pkg}/schemas",
        f"src/{pkg}/services",
        f"src/{pkg}/config",
    ]:
        (tmp_path / rel).mkdir(parents=True, exist_ok=True)
    (tmp_path / "src" / pkg / "__init__.py").write_text("")
    (tmp_path / f"src/{pkg}/config/settings.py").write_text("settings = {}\n")
    (tmp_path / "main.py").write_text("app = None\n")
    _write_pyproject(tmp_path, deps=["fastapi", "uvicorn"])
    body = "\n    x = 1\n" * 60
    (tmp_path / f"src/{pkg}/api/v1/users.py").write_text(
        "from fastapi import APIRouter\nfrom test_repo.services import user_service\n"
        "router = APIRouter()\n"
        "@router.get('/users')\nasync def list_users():"
        f"{body}    return []\n"
    )
    group = ps.check_thin_routes(ps.ProjectType.BACKEND, ctx)
    assert group.overall() == ps.CheckStatus.WARN, (
        f"over-limit but no forbidden import → WARN, got {group.checks}"
    )
    assert any(c.name == "route imports" and c.status == ps.CheckStatus.OK for c in group.checks)


# ── check_thin_routes: FULLSTACK (issue #259) ──────────────────────────────


def test_thin_routes_fullstack_ok(tmp_path, ctx):
    """Fullstack nested ``backend/src/<pkg>/api/v1/`` → thin-routes OK (new)."""
    _make_fullstack_repo(tmp_path)
    group = ps.check_thin_routes(ps.ProjectType.FULLSTACK, ctx)
    assert group.overall() == ps.CheckStatus.OK, (
        f"fullstack thin-routes check should work now, got {group.checks}"
    )
    assert any(c.name == "route imports" and c.status == ps.CheckStatus.OK for c in group.checks)


def test_thin_routes_fullstack_no_pkg_dir_skip(tmp_path, ctx):
    """Fullstack without ``backend/src/<pkg>/`` (flat) → WARN skip (no api dir)."""
    (tmp_path / "backend").mkdir()
    (tmp_path / "frontend").mkdir()
    group = ps.check_thin_routes(ps.ProjectType.FULLSTACK, ctx)
    assert group.overall() == ps.CheckStatus.WARN
    assert any(c.name == "src/api/v1/" and "не найдена" in c.detail for c in group.checks)


def test_thin_routes_fullstack_no_backend_pyproject_skip(tmp_path, ctx):
    """Fullstack without ``backend/pyproject.toml`` → cannot resolve pkg → skip."""
    pkg = "test_repo"
    for rel in [
        f"backend/src/{pkg}/api/v1",
        f"backend/src/{pkg}/db/models",
        f"backend/src/{pkg}/schemas",
        f"backend/src/{pkg}/services",
        f"backend/src/{pkg}/config",
        "frontend",
    ]:
        (tmp_path / rel).mkdir(parents=True, exist_ok=True)
    (tmp_path / "backend" / "src" / pkg / "__init__.py").write_text("")
    (tmp_path / "backend" / f"src/{pkg}/api/v1/users.py").write_text(
        "from fastapi import APIRouter\nrouter = APIRouter()\n"
        "@router.get('/users')\nasync def list_users():\n    return []\n"
    )
    group = ps.check_thin_routes(ps.ProjectType.FULLSTACK, ctx)
    assert group.overall() == ps.CheckStatus.WARN
    assert any(c.name == "src/api/v1/" and "не найдена" in c.detail for c in group.checks), (
        f"expected WARN skip when backend pyproject missing, got {group.checks}"
    )


def test_thin_routes_fullstack_import_src_db_models_warn(tmp_path, ctx):
    """Fullstack route importing ``src.db.models`` → WARN (issue #275: non-blocking)."""
    _make_fullstack_repo(tmp_path)
    (tmp_path / "backend" / "src/test_repo/api/v1/users.py").write_text(
        "from fastapi import APIRouter\nfrom src.db.models import User\n"
        "router = APIRouter()\n"
        "@router.get('/users')\nasync def list_users():\n    return []\n"
    )
    group = ps.check_thin_routes(ps.ProjectType.FULLSTACK, ctx)
    assert any(
        c.name == "route imports" and c.status == ps.CheckStatus.WARN for c in group.checks
    ), f"expected WARN on fullstack route imports, got: {group.checks}"


def test_thin_routes_fullstack_import_tortoise_warn(tmp_path, ctx):
    """Fullstack route importing ``tortoise`` → WARN (issue #275: non-blocking)."""
    _make_fullstack_repo(tmp_path)
    (tmp_path / "backend" / "src/test_repo/api/v1/users.py").write_text(
        "from fastapi import APIRouter\nfrom tortoise import fields\n"
        "router = APIRouter()\n"
        "@router.get('/users')\nasync def list_users():\n    return []\n"
    )
    group = ps.check_thin_routes(ps.ProjectType.FULLSTACK, ctx)
    assert any(
        c.name == "route imports" and c.status == ps.CheckStatus.WARN for c in group.checks
    ), f"expected WARN on fullstack route imports, got: {group.checks}"


def test_thin_routes_fullstack_import_services_ok(tmp_path, ctx):
    """Fullstack route importing ``<pkg>.services`` → OK (not forbidden)."""
    _make_fullstack_repo(tmp_path)
    (tmp_path / "backend" / "src/test_repo/api/v1/users.py").write_text(
        "from fastapi import APIRouter\nfrom test_repo.services import user_service\n"
        "router = APIRouter()\n"
        "@router.get('/users')\nasync def list_users():\n    return user_service.list_all()\n"
    )
    group = ps.check_thin_routes(ps.ProjectType.FULLSTACK, ctx)
    assert all(
        c.status != ps.CheckStatus.WARN or c.name != "route imports" for c in group.checks
    ), f"fullstack services import should be OK, got: {group.checks}"


def test_thin_routes_fullstack_over_limit_warn(tmp_path, ctx):
    """Fullstack route over line limit → WARN (no FAIL) when imports clean."""
    _make_fullstack_repo(tmp_path)
    body = "\n    x = 1\n" * 60
    (tmp_path / "backend" / "src/test_repo/api/v1/users.py").write_text(
        "from fastapi import APIRouter\nfrom test_repo.services import user_service\n"
        "router = APIRouter()\n"
        "@router.get('/users')\nasync def list_users():"
        f"{body}    return []\n"
    )
    group = ps.check_thin_routes(ps.ProjectType.FULLSTACK, ctx)
    assert group.overall() == ps.CheckStatus.WARN, (
        f"over-limit but no forbidden import → WARN, got {group.checks}"
    )
    assert any(c.name == "route imports" and c.status == ps.CheckStatus.OK for c in group.checks)


# ── check_quality ────────────────────────────────────────────────────────────


def test_quality_ok(tmp_path, ctx):
    _make_backend_repo(tmp_path)
    group = ps.check_quality(ps.ProjectType.BACKEND, ctx)
    assert group.overall() == ps.CheckStatus.OK


def test_quality_missing_mypy(tmp_path, ctx):
    _write_pyproject(tmp_path, deps=["fastapi"], has_mypy=False)
    group = ps.check_quality(ps.ProjectType.BACKEND, ctx)
    assert any(c.name == "mypy" and c.status == ps.CheckStatus.WARN for c in group.checks)


def test_quality_missing_ruff(tmp_path, ctx):
    _write_pyproject(tmp_path, deps=["fastapi"], has_ruff=False)
    group = ps.check_quality(ps.ProjectType.BACKEND, ctx)
    assert any(c.name == "ruff" and c.status == ps.CheckStatus.WARN for c in group.checks)


# ── check_tests ──────────────────────────────────────────────────────────────


def test_tests_ok(tmp_path, ctx):
    _make_backend_repo(tmp_path)
    group = ps.check_tests(ps.ProjectType.BACKEND, ctx)
    assert group.overall() == ps.CheckStatus.OK


def test_tests_no_dir(tmp_path, ctx):
    group = ps.check_tests(ps.ProjectType.BACKEND, ctx)
    assert group.overall() == ps.CheckStatus.WARN
    assert any("tests/" in c.name for c in group.checks)


def test_tests_no_conftest(tmp_path, ctx):
    (tmp_path / "tests").mkdir()
    (tmp_path / "tests/test_x.py").write_text("def test_x(): assert True\n")
    group = ps.check_tests(ps.ProjectType.BACKEND, ctx)
    assert any(c.name == "conftest.py" and c.status == ps.CheckStatus.WARN for c in group.checks)


def test_tests_asyncio_mark_warns(tmp_path, ctx):
    (tmp_path / "tests").mkdir()
    (tmp_path / "tests/test_x.py").write_text(
        "import pytest\n@pytest.mark.asyncio\nasync def test_x(): assert True\n"
    )
    group = ps.check_tests(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "no @pytest.mark.asyncio" and c.status == ps.CheckStatus.WARN
        for c in group.checks
    )


def test_tests_backend_missing_unit_warn(tmp_path, ctx):
    """Backend без ``tests/unit/`` → WARN (не FAIL)."""
    _make_backend_repo(tmp_path)
    (tmp_path / "tests/unit/test_user_service.py").unlink()
    (tmp_path / "tests/unit").rmdir()
    group = ps.check_tests(ps.ProjectType.BACKEND, ctx)
    assert any(c.name == "tests/unit/" and c.status == ps.CheckStatus.WARN for c in group.checks)


def test_tests_backend_missing_api_warn(tmp_path, ctx):
    """Backend без ``tests/api/`` → WARN."""
    _make_backend_repo(tmp_path)
    (tmp_path / "tests/api/test_users.py").unlink()
    (tmp_path / "tests/api").rmdir()
    group = ps.check_tests(ps.ProjectType.BACKEND, ctx)
    assert any(c.name == "tests/api/" and c.status == ps.CheckStatus.WARN for c in group.checks)


def test_tests_backend_with_unit_api_ok(tmp_path, ctx):
    """Backend с tests/unit/ + tests/api/ → OK on structure (default _make_backend_repo)."""
    _make_backend_repo(tmp_path)
    group = ps.check_tests(ps.ProjectType.BACKEND, ctx)
    assert any(c.name == "tests/unit/" and c.status == ps.CheckStatus.OK for c in group.checks)
    assert any(c.name == "tests/api/" and c.status == ps.CheckStatus.OK for c in group.checks)


def test_tests_cli_no_unit_api_ok(tmp_path, ctx):
    """CLI без tests/unit/ + tests/api/ → OK (cli не имеет API layer)."""
    (tmp_path / "tests").mkdir()
    (tmp_path / "tests/conftest.py").write_text("import pytest\n")
    (tmp_path / "tests/test_cli.py").write_text("def test_cli(): assert True\n")
    group = ps.check_tests(ps.ProjectType.CLI, ctx)
    assert not any(c.name == "tests/unit/" for c in group.checks)
    assert not any(c.name == "tests/api/" for c in group.checks)
    assert not any(c.name == "tests/integration/ pytestmark" for c in group.checks)


def test_tests_integration_without_pytestmark_warn(tmp_path, ctx):
    """tests/integration/test_*.py без pytestmark → WARN."""
    _make_backend_repo(tmp_path)
    (tmp_path / "tests/integration").mkdir()
    (tmp_path / "tests/integration/test_flow.py").write_text("def test_flow(): assert True\n")
    group = ps.check_tests(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "tests/integration/ pytestmark" and c.status == ps.CheckStatus.WARN
        for c in group.checks
    )


def test_tests_integration_with_pytestmark_ok(tmp_path, ctx):
    """tests/integration/test_*.py с pytest.mark.integration → OK."""
    _make_backend_repo(tmp_path)
    (tmp_path / "tests/integration").mkdir()
    (tmp_path / "tests/integration/test_flow.py").write_text(
        "import pytest\npytestmark = [pytest.mark.integration]\ndef test_flow(): assert True\n"
    )
    group = ps.check_tests(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "tests/integration/ pytestmark" and c.status == ps.CheckStatus.OK
        for c in group.checks
    )


def test_tests_integration_empty_dir_warn(tmp_path, ctx):
    """tests/integration/ пустая → WARN."""
    _make_backend_repo(tmp_path)
    (tmp_path / "tests/integration").mkdir()
    group = ps.check_tests(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "tests/integration/" and c.status == ps.CheckStatus.WARN for c in group.checks
    )


def test_tests_stub_file_without_def_test_warn(tmp_path, ctx):
    """test_*.py без ``def test_*``/``async def test_*`` → WARN (stub)."""
    (tmp_path / "tests").mkdir()
    (tmp_path / "tests/conftest.py").write_text("import pytest\n")
    (tmp_path / "tests/test_stub.py").write_text("# empty stub file\nx = 1\n")
    (tmp_path / "tests/test_real.py").write_text("def test_real(): assert True\n")
    group = ps.check_tests(ps.ProjectType.BACKEND, ctx)
    assert any(c.name == "stub-detector" and c.status == ps.CheckStatus.WARN for c in group.checks)


def test_tests_stub_detector_async_def_test_ok(tmp_path, ctx):
    """``async def test_*`` распознаётся как реальный тест (не stub)."""
    (tmp_path / "tests").mkdir()
    (tmp_path / "tests/conftest.py").write_text("import pytest\n")
    (tmp_path / "tests/test_async.py").write_text("async def test_async(): assert True\n")
    group = ps.check_tests(ps.ProjectType.BACKEND, ctx)
    assert any(c.name == "stub-detector" and c.status == ps.CheckStatus.OK for c in group.checks)


def test_tests_stub_detector_real_test_ok(tmp_path, ctx):
    """test_*.py с ``def test_*`` → OK (не stub)."""
    (tmp_path / "tests").mkdir()
    (tmp_path / "tests/conftest.py").write_text("import pytest\n")
    (tmp_path / "tests/test_x.py").write_text("def test_x(): assert True\n")
    group = ps.check_tests(ps.ProjectType.BACKEND, ctx)
    assert any(c.name == "stub-detector" and c.status == ps.CheckStatus.OK for c in group.checks)


# ── check_readme ─────────────────────────────────────────────────────────────


def _write_valid_readme(tmp_path: Path) -> None:
    content = (
        "# 🚀 Title\n[English](#-english) [Русский](#-русский)\n"
        "## 🇺🇸 English\n## 🇷🇺 Русский\nassets/cover.png\n"
        "Quick Start\nБыстрый старт\nslaid098.dev/contacts\n"
    )
    for d in ps.README_DELIMITERS:
        content += f"<!-- {d} -->\n"
    (tmp_path / "README.md").write_text(content)


def test_readme_ok(tmp_path, ctx):
    _write_valid_readme(tmp_path)
    group = ps.check_readme(ps.ProjectType.BACKEND, ctx)
    assert group.overall() == ps.CheckStatus.OK, (
        f"expected OK, got {group.overall()}: "
        + ", ".join(
            f"{c.name}={c.status.value}" for c in group.checks if c.status != ps.CheckStatus.OK
        )
    )


def test_readme_missing(tmp_path, ctx):
    group = ps.check_readme(ps.ProjectType.BACKEND, ctx)
    assert group.overall() == ps.CheckStatus.WARN
    assert any(c.name == "README.md" and c.status == ps.CheckStatus.WARN for c in group.checks)


def test_readme_missing_delimiters(tmp_path, ctx):
    (tmp_path / "README.md").write_text(
        "# 🚀 Title\n## 🇺🇸 English\n## 🇷🇺 Русский\n[English](#-english)\n"
        "[Русский](#-русский)\nQuick Start\nБыстрый старт\nslaid098.dev/contacts\n"
    )
    group = ps.check_readme(ps.ProjectType.BACKEND, ctx)
    assert any("delimiter" in c.name and c.status == ps.CheckStatus.WARN for c in group.checks)


def test_readme_missing_ru_switcher_warn(tmp_path, ctx):
    """README без ``[Русский](#-русский)`` → WARN (issue #275: non-blocking)."""
    _write_valid_readme(tmp_path)
    content = (tmp_path / "README.md").read_text().replace("[Русский](#-русский)", "")
    (tmp_path / "README.md").write_text(content)
    group = ps.check_readme(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "[Русский](#-русский)" and c.status == ps.CheckStatus.WARN for c in group.checks
    )


def test_readme_missing_support_link_warn(tmp_path, ctx):
    """README без обеих ссылок (contacts/support) → WARN (issue #275)."""
    _write_valid_readme(tmp_path)
    content = (tmp_path / "README.md").read_text().replace("slaid098.dev/contacts", "")
    (tmp_path / "README.md").write_text(content)
    group = ps.check_readme(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "slaid098.dev/contacts" and c.status == ps.CheckStatus.WARN for c in group.checks
    )
    assert group.overall() == ps.CheckStatus.WARN


def test_readme_deprecated_support_link_warn(tmp_path, ctx):
    """README только со старой ссылкой ``slaid098.dev/support`` → WARN (не FAIL)."""
    _write_valid_readme(tmp_path)
    content = (
        (tmp_path / "README.md")
        .read_text()
        .replace("slaid098.dev/contacts", "slaid098.dev/support")
    )
    (tmp_path / "README.md").write_text(content)
    group = ps.check_readme(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "slaid098.dev/contacts" and c.status == ps.CheckStatus.WARN for c in group.checks
    )
    assert group.overall() == ps.CheckStatus.WARN


def test_readme_contacts_link_ok(tmp_path, ctx):
    """README со ссылкой ``slaid098.dev/contacts`` → OK."""
    _write_valid_readme(tmp_path)
    group = ps.check_readme(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "slaid098.dev/contacts" and c.status == ps.CheckStatus.OK for c in group.checks
    )


def test_readme_missing_quick_start_warn(tmp_path, ctx):
    """README без ``Quick Start`` (EN) → WARN (issue #275: non-blocking)."""
    _write_valid_readme(tmp_path)
    content = (tmp_path / "README.md").read_text().replace("Quick Start", "")
    (tmp_path / "README.md").write_text(content)
    group = ps.check_readme(ps.ProjectType.BACKEND, ctx)
    assert any(c.name == "Quick Start" and c.status == ps.CheckStatus.WARN for c in group.checks)


def test_readme_missing_bystriy_start_warn(tmp_path, ctx):
    """README без ``Быстрый старт`` (RU) → WARN (issue #275: non-blocking)."""
    _write_valid_readme(tmp_path)
    content = (tmp_path / "README.md").read_text().replace("Быстрый старт", "")
    (tmp_path / "README.md").write_text(content)
    group = ps.check_readme(ps.ProjectType.BACKEND, ctx)
    assert any(c.name == "Быстрый старт" and c.status == ps.CheckStatus.WARN for c in group.checks)


def test_readme_manual_license_section_warn(tmp_path, ctx):
    """README с ручной секцией ``## License`` → WARN (issue #275: non-blocking)."""
    _write_valid_readme(tmp_path)
    content = (tmp_path / "README.md").read_text() + "\n## License\nMIT\n"
    (tmp_path / "README.md").write_text(content)
    group = ps.check_readme(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "Manual License section" and c.status == ps.CheckStatus.WARN for c in group.checks
    )


def test_readme_manual_license_ru_section_warn(tmp_path, ctx):
    """README с ``## Лицензия`` (RU) → WARN (issue #275: non-blocking)."""
    _write_valid_readme(tmp_path)
    content = (tmp_path / "README.md").read_text() + "\n## Лицензия\nMIT\n"
    (tmp_path / "README.md").write_text(content)
    group = ps.check_readme(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "Manual License section" and c.status == ps.CheckStatus.WARN for c in group.checks
    )


def test_readme_no_manual_license_ok(tmp_path, ctx):
    """README без ручной License секции → нет FAIL для ``Manual License section``."""
    _write_valid_readme(tmp_path)
    group = ps.check_readme(ps.ProjectType.BACKEND, ctx)
    assert not any(c.name == "Manual License section" for c in group.checks)


# ── check_infra ──────────────────────────────────────────────────────────────


def test_infra_ok_with_branch_protection(monkeypatch, tmp_path, ctx):
    for rel in [".github/workflows", ".github"]:
        (tmp_path / rel).mkdir(parents=True, exist_ok=True)
    (tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n")
    (tmp_path / ".github/dependabot.yml").write_text("version: 2\n")
    (tmp_path / "LICENSE").write_text("MIT\n")
    (tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n")
    monkeypatch.setattr(
        ps,
        "run_cmd",
        mock_run_cmd(
            {
                ("gh", "api"): (
                    0,
                    '{"rules":[{"type":"pull_request"},{"type":"required_status_checks"}]}',
                    "",
                ),
            }
        ),
    )
    group = ps.check_infra(ps.ProjectType.BACKEND, ctx, fast=False)
    assert group.overall() == ps.CheckStatus.OK


def test_infra_missing_ci_warn(tmp_path, ctx):
    (tmp_path / "LICENSE").write_text("MIT\n")
    group = ps.check_infra(ps.ProjectType.BACKEND, ctx, fast=True)
    assert any(
        c.name == ".github/workflows/ci.yml" and c.status == ps.CheckStatus.WARN
        for c in group.checks
    )


def test_infra_fast_skips_branch_protection(tmp_path, ctx):
    (tmp_path / ".github/workflows").mkdir(parents=True, exist_ok=True)
    (tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n")
    (tmp_path / "LICENSE").write_text("MIT\n")
    group = ps.check_infra(ps.ProjectType.BACKEND, ctx, fast=True)
    assert any(
        c.name == "branch protection" and c.status == ps.CheckStatus.WARN and "--fast" in c.detail
        for c in group.checks
    )


def test_infra_branch_protection_gh_error(monkeypatch, tmp_path, ctx):
    (tmp_path / ".github/workflows").mkdir(parents=True, exist_ok=True)
    (tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n")
    (tmp_path / "LICENSE").write_text("MIT\n")
    monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("gh", "api"): (1, "", "not found")}))
    group = ps.check_infra(ps.ProjectType.BACKEND, ctx, fast=False)
    assert any(
        c.name == "branch protection" and c.status == ps.CheckStatus.WARN for c in group.checks
    )


# ── check_coverage ───────────────────────────────────────────────────────────


def test_coverage_non_blocking(tmp_path, ctx):
    _write_pyproject(tmp_path, deps=["fastapi"], cov_source=["src"], cov_fail="80")
    group = ps.check_coverage(ps.ProjectType.BACKEND, ctx)
    assert group.overall() == ps.CheckStatus.OK
    assert all(c.status != ps.CheckStatus.FAIL for c in group.checks)


def test_coverage_no_config(tmp_path, ctx):
    _write_pyproject(tmp_path, deps=["fastapi"], has_pytest=False)
    group = ps.check_coverage(ps.ProjectType.BACKEND, ctx)
    assert group.overall() == ps.CheckStatus.WARN
    assert all(c.status != ps.CheckStatus.FAIL for c in group.checks)


# ── format_output ────────────────────────────────────────────────────────────


def test_format_output_has_summary_and_recommendations():
    ptype = ps.ProjectType.BACKEND
    groups = [
        ps.GroupResult(
            name="Структура",
            checks=[
                ps.CheckResult(ps.CheckStatus.OK, "src/api/v1", "ok"),
                ps.CheckResult(ps.CheckStatus.FAIL, "main.py", "missing"),
            ],
        ),
        ps.GroupResult(name="Coverage", checks=[ps.CheckResult(ps.CheckStatus.OK, "cov", "ok")]),
    ]
    out = ps.format_output(ptype, groups)
    assert "Project: backend" in out
    assert "[OK] Структура" not in out
    assert "[FAIL] Структура" in out
    assert "Итог:" in out
    assert "Рекомендации:" in out
    assert "main.py" in out


def test_format_output_no_recommendations_when_all_ok():
    groups = [
        ps.GroupResult(name="Структура", checks=[ps.CheckResult(ps.CheckStatus.OK, "x", "ok")]),
    ]
    out = ps.format_output(ps.ProjectType.BACKEND, groups)
    assert "Рекомендации:" not in out
    assert "WARN: 0" in out


# ── main / exit codes ────────────────────────────────────────────────────────


def test_main_non_blocking_exit_0(monkeypatch, tmp_path, capsys):
    _make_backend_repo(tmp_path)
    monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("gh", "api"): (1, "", "no auth")}))
    monkeypatch.setattr("sys.argv", ["project-status.py"])
    with pytest.raises(SystemExit) as exc:
        ps.main()
    assert exc.value.code == 0
    captured = capsys.readouterr()
    assert "Project:" in captured.out
    assert "Итог:" in captured.out


def test_main_check_flag_exit_0_always(monkeypatch, tmp_path, capsys):
    """``--check`` is accepted but exit code is always 0 (issue #275).

    Previously ``--check`` forced exit 1 on FAIL; with the all-WARN contract
    no check returns FAIL and the exit code is informational (always 0).
    """
    monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("gh", "api"): (1, "", "no auth")}))
    monkeypatch.setattr("sys.argv", ["project-status.py", "--check"])
    with pytest.raises(SystemExit) as exc:
        ps.main()
    assert exc.value.code == 0


def test_main_check_flag_exit_0_when_all_ok(monkeypatch, tmp_path, capsys):
    """``--check`` with a healthy repo → exit 0 (issue #275: always 0)."""
    _make_backend_repo(tmp_path)
    for rel in [".github/workflows", ".github"]:
        (tmp_path / rel).mkdir(parents=True, exist_ok=True)
    (tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n")
    (tmp_path / ".github/dependabot.yml").write_text("version: 2\n")
    (tmp_path / "LICENSE").write_text("MIT\n")
    (tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n")
    _write_valid_readme(tmp_path)
    monkeypatch.setattr(
        ps,
        "run_cmd",
        mock_run_cmd(
            {
                ("gh", "api"): (
                    0,
                    '{"rules":[{"type":"pull_request"},{"type":"required_status_checks"}]}',
                    "",
                ),
            }
        ),
    )
    monkeypatch.setattr("sys.argv", ["project-status.py", "--check"])
    with pytest.raises(SystemExit) as exc:
        ps.main()
    captured = capsys.readouterr()
    assert exc.value.code == 0, f"expected exit 0, got {exc.value.code}\n{captured.out}"


def test_main_fast_flag(monkeypatch, tmp_path, capsys):
    _make_backend_repo(tmp_path)
    monkeypatch.setattr(
        ps, "run_cmd", mock_run_cmd({("gh", "api"): (1, "", "should not be called")})
    )
    monkeypatch.setattr("sys.argv", ["project-status.py", "--fast"])
    with pytest.raises(SystemExit) as exc:
        ps.main()
    assert exc.value.code == 0
    captured = capsys.readouterr()
    assert "--fast" in captured.out


# ── run_all_checks integration ───────────────────────────────────────────────


def test_run_all_checks_returns_8_groups(tmp_path, ctx):
    _make_backend_repo(tmp_path)
    groups = ps.run_all_checks(ps.ProjectType.BACKEND, ctx, fast=True)
    assert len(groups) == 8
    assert [g.name for g in groups] == ps.CHECK_GROUPS


# ── check_pyproject (group 8, 13 checks) ─────────────────────────────────────


def _write_full_pyproject(  # noqa: C901, PLR0912, PLR0915
    tmp_path: Path,
    *,
    name: str = "test-repo",
    description: str = "test repo",
    requires_python: str = ">=3.12",
    has_build_system: bool = True,
    has_project_fields: bool = True,
    has_ruff: bool = True,
    has_mypy: bool = True,
    mypy_strict: bool = True,
    has_pytest: bool = True,
    pytest_asyncio_auto: bool = True,
    pytest_testpaths_tests: bool = True,
    has_cov_run: bool = True,
    cov_branch: bool = True,
    has_cov_report_exclude: bool = True,
    has_cov_fail_under: bool = True,
    cov_fail: str = "80",
    has_project_status_section: bool = False,
    has_pre_commit: bool = True,
    has_uv_lock: bool = True,
    has_python_version: bool = True,
    python_version_content: str = "3.12\n",
    ruff_toml: bool = False,
    mypy_ini: bool = False,
    src_pkg_exists: bool = False,
    hatch_packages_override: list | None = None,
) -> None:
    """Write a pyproject.toml with fine-grained control over every check section.

    Used by the 13-check test suite for ``check_pyproject``. Each kwarg
    toggles a specific check's pass/warn/fail condition.
    """
    norm_name = ps._normalize_package_name(name)
    lines: list[str] = []
    if has_build_system:
        lines.extend(
            [
                "[build-system]",
                'requires = ["hatchling"]',
                'build-backend = "hatchling.build"',
                "",
            ]
        )
    project_lines = ["[project]", f'name = "{name}"', 'version = "0.1.0"']
    if has_project_fields:
        project_lines.append(f'description = "{description}"')
        project_lines.append(f'requires-python = "{requires_python}"')
    lines.extend([*project_lines, "", "dependencies = []", ""])
    if src_pkg_exists:
        (tmp_path / "src" / norm_name).mkdir(parents=True, exist_ok=True)
        (tmp_path / "src" / norm_name / "__init__.py").write_text("")
        lines.append("[tool.hatch.build.targets.wheel]")
        lines.append(f'packages = ["src/{norm_name}"]')
        lines.append("")
    elif hatch_packages_override is not None:
        lines.append("[tool.hatch.build.targets.wheel]")
        pkgs_str = ", ".join(f'"{p}"' for p in hatch_packages_override)
        lines.append(f"packages = [{pkgs_str}]")
        lines.append("")
    if has_ruff and not ruff_toml:
        lines.extend(["[tool.ruff]", 'target-version = "py312"', "line-length = 100", ""])
    if ruff_toml:
        (tmp_path / "ruff.toml").write_text("line-length = 100\ntarget-version = py312\n")
    if has_mypy and not mypy_ini:
        lines.extend(["[tool.mypy]"])
        if mypy_strict:
            lines.append("strict = true")
        else:
            lines.append('python_version = "3.12"')
        lines.append("")
    if mypy_ini:
        (tmp_path / "mypy.ini").write_text("[mypy]\nstrict = True\n")
    if has_pytest:
        lines.append("[tool.pytest.ini_options]")
        if pytest_asyncio_auto:
            lines.append('asyncio_mode = "auto"')
        if pytest_testpaths_tests:
            lines.append('testpaths = ["tests"]')
        addopts = "--cov=src --cov-report=term-missing"
        if has_cov_fail_under and cov_fail:
            addopts += f" --cov-fail-under={cov_fail}"
        lines.append(f'addopts = "{addopts}"')
        lines.append("")
    if has_cov_run:
        lines.append("[tool.coverage.run]")
        lines.append('source = ["src"]')
        if cov_branch:
            lines.append("branch = true")
        lines.append("")
    if has_cov_report_exclude:
        lines.append("[tool.coverage.report]")
        lines.append("exclude_lines = [")
        lines.append('    "pragma: no cover",')
        lines.append('    "if __name__ == .__main__.:",')
        lines.append('    "if TYPE_CHECKING:",')
        lines.append("]")
        lines.append("")
    if has_project_status_section:
        lines.extend(
            [
                "[tool.project-status]",
                "thin_routes_max_lines = 50",
                "cov_fail_under = 80",
                'required_dirs_backend = ["src/api/v1"]',
                "",
            ]
        )
    (tmp_path / "pyproject.toml").write_text("\n".join(lines))
    if has_pre_commit:
        (tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n")
    if has_uv_lock:
        (tmp_path / "uv.lock").write_text("# lockfile\n")
    if has_python_version:
        (tmp_path / ".python-version").write_text(python_version_content)


def test_check_pyproject_all_ok(tmp_path, ctx):
    _write_full_pyproject(tmp_path, has_project_status_section=True, src_pkg_exists=True)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert group.overall() == ps.CheckStatus.OK, (
        f"expected OK, got {group.overall()}: "
        + ", ".join(f"{c.name}={c.status.value}" for c in group.checks)
    )
    assert len(group.checks) == 13, f"expected 13 checks, got {len(group.checks)}"


def test_check_pyproject_no_pyproject_warn(tmp_path, ctx):
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert group.overall() == ps.CheckStatus.WARN
    assert any(c.name == "pyproject.toml" and c.status == ps.CheckStatus.WARN for c in group.checks)
    assert len(group.checks) == 1


def test_check_pyproject_invalid_toml_warn(tmp_path, ctx):
    (tmp_path / "pyproject.toml").write_text("not valid = = =")
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert group.overall() == ps.CheckStatus.WARN
    assert any("парсинг" in c.detail for c in group.checks)


def test_check_pyproject_check1_build_system_warn(tmp_path, ctx):
    _write_full_pyproject(tmp_path, has_build_system=False, src_pkg_exists=True)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(c.name == "[build-system]" and c.status == ps.CheckStatus.WARN for c in group.checks)


def test_check_pyproject_check2_hatch_wheel_ok_with_src_pkg(tmp_path, ctx):
    _write_full_pyproject(tmp_path, src_pkg_exists=True)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "[tool.hatch.build.targets.wheel]" and c.status == ps.CheckStatus.OK
        for c in group.checks
    )


def test_check_pyproject_check2_hatch_wheel_warn_missing_packages(tmp_path, ctx):
    """src/<pkg>/ exists but packages doesn't reference it → WARN (issue #275)."""
    (tmp_path / "src").mkdir()
    (tmp_path / "src" / "test_repo").mkdir()
    (tmp_path / "src" / "test_repo" / "__init__.py").write_text("")
    _write_full_pyproject(tmp_path, src_pkg_exists=False)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "[tool.hatch.build.targets.wheel]" and c.status == ps.CheckStatus.WARN
        for c in group.checks
    )


def test_check_pyproject_check2_hatch_wheel_warn_with_src_root(tmp_path, ctx):
    """packages=["src"] is deprecated flat layout → WARN (issue #241).

    Inverted: ``packages = ["src"]`` was previously accepted as OK (hatchling
    treats ``src/`` as package root). Now only ``packages = ["src/<pkg>"]``
    is valid — ``["src"]`` is a deprecated flat layout.
    """
    (tmp_path / "src").mkdir()
    (tmp_path / "src" / "test_repo").mkdir()
    (tmp_path / "src" / "test_repo" / "__init__.py").write_text("")
    _write_full_pyproject(tmp_path, src_pkg_exists=False, hatch_packages_override=["src"])
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "[tool.hatch.build.targets.wheel]"
        and c.status == ps.CheckStatus.WARN
        and "deprecated flat layout" in c.detail
        for c in group.checks
    )


def test_check_pyproject_check2_hatch_wheel_warn_app_layout(tmp_path, ctx):
    """src/ exists but no src/<pkg>/ → WARN (deprecated flat layout, issue #241)."""
    (tmp_path / "src").mkdir()
    (tmp_path / "src" / "api").mkdir()
    _write_full_pyproject(tmp_path, src_pkg_exists=False)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "[tool.hatch.build.targets.wheel]"
        and c.status == ps.CheckStatus.WARN
        and "deprecated flat layout" in c.detail
        for c in group.checks
    )


def test_check_pyproject_check3_project_fields_warn(tmp_path, ctx):
    _write_full_pyproject(tmp_path, has_project_fields=False, src_pkg_exists=True)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(c.name == "[project]" and c.status == ps.CheckStatus.WARN for c in group.checks)


def test_check_pyproject_check4_ruff_ok(tmp_path, ctx):
    _write_full_pyproject(tmp_path, src_pkg_exists=True)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(c.name == "[tool.ruff]" and c.status == ps.CheckStatus.OK for c in group.checks)


def test_check_pyproject_check4_ruff_toml_ok(tmp_path, ctx):
    """ruff.toml is an accepted alternative to [tool.ruff]."""
    _write_full_pyproject(tmp_path, has_ruff=False, ruff_toml=True, src_pkg_exists=True)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(c.name == "[tool.ruff]" and c.status == ps.CheckStatus.OK for c in group.checks)


def test_check_pyproject_check4_ruff_missing_warn(tmp_path, ctx):
    _write_full_pyproject(tmp_path, has_ruff=False, src_pkg_exists=True)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(c.name == "[tool.ruff]" and c.status == ps.CheckStatus.WARN for c in group.checks)


def test_check_pyproject_check5_mypy_strict_ok(tmp_path, ctx):
    _write_full_pyproject(tmp_path, src_pkg_exists=True)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(c.name == "[tool.mypy]" and c.status == ps.CheckStatus.OK for c in group.checks)


def test_check_pyproject_check5_mypy_ini_ok(tmp_path, ctx):
    """mypy.ini is an accepted alternative to [tool.mypy]."""
    _write_full_pyproject(tmp_path, has_mypy=False, mypy_ini=True, src_pkg_exists=True)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(c.name == "[tool.mypy]" and c.status == ps.CheckStatus.OK for c in group.checks)


def test_check_pyproject_check5_mypy_not_strict_warn(tmp_path, ctx):
    _write_full_pyproject(tmp_path, mypy_strict=False, src_pkg_exists=True)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(c.name == "[tool.mypy]" and c.status == ps.CheckStatus.WARN for c in group.checks)


def test_check_pyproject_check5_mypy_missing_warn(tmp_path, ctx):
    _write_full_pyproject(tmp_path, has_mypy=False, src_pkg_exists=True)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(c.name == "[tool.mypy]" and c.status == ps.CheckStatus.WARN for c in group.checks)


def test_check_pyproject_check6_pytest_ok(tmp_path, ctx):
    _write_full_pyproject(tmp_path, src_pkg_exists=True)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "[tool.pytest.ini_options]" and c.status == ps.CheckStatus.OK
        for c in group.checks
    )


def test_check_pyproject_check6_pytest_missing_warn(tmp_path, ctx):
    _write_full_pyproject(tmp_path, has_pytest=False, src_pkg_exists=True)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "[tool.pytest.ini_options]" and c.status == ps.CheckStatus.WARN
        for c in group.checks
    )


def test_check_pyproject_check6_pytest_wrong_mode_warn(tmp_path, ctx):
    _write_full_pyproject(
        tmp_path, pytest_asyncio_auto=False, pytest_testpaths_tests=False, src_pkg_exists=True
    )
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "[tool.pytest.ini_options]" and c.status == ps.CheckStatus.WARN
        for c in group.checks
    )


def test_check_pyproject_check7_cov_run_ok(tmp_path, ctx):
    _write_full_pyproject(tmp_path, src_pkg_exists=True)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "[tool.coverage.run]" and c.status == ps.CheckStatus.OK for c in group.checks
    )


def test_check_pyproject_check7_cov_run_warn_no_branch(tmp_path, ctx):
    _write_full_pyproject(tmp_path, cov_branch=False, src_pkg_exists=True)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "[tool.coverage.run]" and c.status == ps.CheckStatus.WARN for c in group.checks
    )


def test_check_pyproject_check8_cov_report_exclude_ok(tmp_path, ctx):
    _write_full_pyproject(tmp_path, src_pkg_exists=True)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "[tool.coverage.report]" and c.status == ps.CheckStatus.OK for c in group.checks
    )


def test_check_pyproject_check8_cov_report_exclude_warn(tmp_path, ctx):
    _write_full_pyproject(tmp_path, has_cov_report_exclude=False, src_pkg_exists=True)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "[tool.coverage.report]" and c.status == ps.CheckStatus.WARN for c in group.checks
    )


def test_check_pyproject_check9_cov_fail_under_ok(tmp_path, ctx):
    _write_full_pyproject(tmp_path, src_pkg_exists=True)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "addopts --cov-fail-under" and c.status == ps.CheckStatus.OK for c in group.checks
    )


def test_check_pyproject_check9_cov_fail_under_warn(tmp_path, ctx):
    _write_full_pyproject(tmp_path, has_cov_fail_under=False, src_pkg_exists=True)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "addopts --cov-fail-under" and c.status == ps.CheckStatus.WARN
        for c in group.checks
    )


def test_check_pyproject_check10_project_status_section_ok(tmp_path, ctx):
    _write_full_pyproject(tmp_path, has_project_status_section=True, src_pkg_exists=True)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "[tool.project-status]" and c.status == ps.CheckStatus.OK for c in group.checks
    )


def test_check_pyproject_check10_project_status_section_warn_default(tmp_path, ctx):
    _write_full_pyproject(tmp_path, has_project_status_section=False, src_pkg_exists=True)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "[tool.project-status]" and c.status == ps.CheckStatus.WARN for c in group.checks
    )


def test_check_pyproject_check11_pre_commit_ok(tmp_path, ctx):
    _write_full_pyproject(tmp_path, src_pkg_exists=True)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == ".pre-commit-config.yaml" and c.status == ps.CheckStatus.OK for c in group.checks
    )


def test_check_pyproject_check11_pre_commit_warn(tmp_path, ctx):
    _write_full_pyproject(tmp_path, has_pre_commit=False, src_pkg_exists=True)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == ".pre-commit-config.yaml" and c.status == ps.CheckStatus.WARN
        for c in group.checks
    )


def test_check_pyproject_check12_uv_lock_ok(tmp_path, ctx):
    _write_full_pyproject(tmp_path, src_pkg_exists=True)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(c.name == "uv.lock" and c.status == ps.CheckStatus.OK for c in group.checks)


def test_check_pyproject_check12_uv_lock_warn(tmp_path, ctx):
    _write_full_pyproject(tmp_path, has_uv_lock=False, src_pkg_exists=True)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(c.name == "uv.lock" and c.status == ps.CheckStatus.WARN for c in group.checks)


def test_check_pyproject_check13_python_version_ok(tmp_path, ctx):
    _write_full_pyproject(
        tmp_path, requires_python=">=3.11", python_version_content="3.13\n", src_pkg_exists=True
    )
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "requires-python vs .python-version" and c.status == ps.CheckStatus.OK
        for c in group.checks
    )


def test_check_pyproject_check13_python_version_warn_incompat(tmp_path, ctx):
    _write_full_pyproject(
        tmp_path, requires_python=">=3.11", python_version_content="3.10\n", src_pkg_exists=True
    )
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "requires-python vs .python-version" and c.status == ps.CheckStatus.WARN
        for c in group.checks
    )


def test_check_pyproject_check13_skip_when_no_python_version(tmp_path, ctx):
    _write_full_pyproject(tmp_path, has_python_version=False, src_pkg_exists=True)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "requires-python vs .python-version"
        and c.status == ps.CheckStatus.WARN
        and "skip" in c.detail
        for c in group.checks
    )


# ── normalize_package_name ───────────────────────────────────────────────────


def test_normalize_package_name():
    assert ps._normalize_package_name("my-project") == "my_project"
    assert ps._normalize_package_name("My.Project") == "my_project"
    assert ps._normalize_package_name("my_project") == "my_project"
    assert ps._normalize_package_name("MY-PROJECT") == "my_project"


# ── --repo flag ──────────────────────────────────────────────────────────────


def test_main_repo_flag_nonexistent_path_exits_1(monkeypatch, tmp_path, capsys):
    """--repo with non-existent path → exit 1 + error message."""
    monkeypatch.setattr("sys.argv", ["project-status.py", "--repo", str(tmp_path / "nonexistent")])
    with pytest.raises(SystemExit) as exc:
        ps.main()
    assert exc.value.code == 1
    captured = capsys.readouterr()
    assert "repo path not found" in captured.out


def test_main_repo_flag_overrides_repo_root(monkeypatch, tmp_path, capsys):
    """--repo with valid path → REPO_ROOT overridden, checks run against it."""
    _make_backend_repo(tmp_path)
    for rel in [".github/workflows", ".github"]:
        (tmp_path / rel).mkdir(parents=True, exist_ok=True)
    (tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n")
    (tmp_path / ".github/dependabot.yml").write_text("version: 2\n")
    (tmp_path / "LICENSE").write_text("MIT\n")
    _write_valid_readme(tmp_path)
    monkeypatch.setattr(
        ps,
        "run_cmd",
        mock_run_cmd(
            {
                ("gh", "api"): (
                    0,
                    '{"rules":[{"type":"pull_request"},{"type":"required_status_checks"}]}',
                    "",
                ),
            }
        ),
    )
    monkeypatch.setattr("sys.argv", ["project-status.py", "--repo", str(tmp_path)])
    with pytest.raises(SystemExit) as exc:
        ps.main()
    assert exc.value.code == 0, f"expected exit 0, got {exc.value.code}"
    captured = capsys.readouterr()
    assert "Project:" in captured.out
    assert "Итог:" in captured.out


def test_main_repo_flag_relative_path(monkeypatch, tmp_path, capsys):
    """--repo with relative path → resolved against cwd."""
    _make_backend_repo(tmp_path)
    for rel in [".github/workflows", ".github"]:
        (tmp_path / rel).mkdir(parents=True, exist_ok=True)
    (tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n")
    (tmp_path / "LICENSE").write_text("MIT\n")
    monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("gh", "api"): (1, "", "no auth")}))
    # cwd = tmp_path, repo = "." (relative)
    monkeypatch.chdir(tmp_path)
    monkeypatch.setattr("sys.argv", ["project-status.py", "--repo", "."])
    with pytest.raises(SystemExit) as exc:
        ps.main()
    assert exc.value.code == 0
    captured = capsys.readouterr()
    assert "Project:" in captured.out


def test_check_pyproject_uses_repo_root(tmp_path, ctx):
    """check_pyproject reads pyproject.toml from REPO_ROOT (tmp_path fixture)."""
    _write_full_pyproject(tmp_path, src_pkg_exists=True, has_project_status_section=True)
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert group.overall() == ps.CheckStatus.OK, (
        f"expected OK, got {group.overall()}: "
        + ", ".join(
            f"{c.name}={c.status.value}" for c in group.checks if c.status != ps.CheckStatus.OK
        )
    )


# ── flat-layout check in check_structure ──────────────────────────────────────


def test_check_structure_flat_layout_warn_for_cli(tmp_path, ctx):
    """CLI/UNKNOWN with src/ but no nested package → WARN flat src/."""
    (tmp_path / "src").mkdir()
    (tmp_path / "src" / "api").mkdir()
    (tmp_path / "src" / "db").mkdir()
    _write_pyproject(tmp_path, deps=["typer"], scripts={"mycli": "mycli.cli:main"})
    group = ps.check_structure(ps.ProjectType.CLI, ctx)
    assert any(
        c.name == "flat src/ layout" and c.status == ps.CheckStatus.WARN for c in group.checks
    )


def test_check_structure_flat_warn_for_backend(tmp_path, ctx):
    """Backend with flat src/api/ (no nested package) → WARN flat src/ layout.

    Inverted by issue #241: nested ``src/<package>/`` is the standard for
    ALL types (backend included). Flat ``src/`` is deprecated → WARN.
    """
    for rel in ["src/api/v1", "src/db/models", "src/schemas", "src/services", "src/config"]:
        (tmp_path / rel).mkdir(parents=True, exist_ok=True)
    (tmp_path / "src/config/settings.py").write_text("settings = {}\n")
    _write_pyproject(tmp_path, deps=["fastapi", "uvicorn"])
    group = ps.check_structure(ps.ProjectType.BACKEND, ctx)
    assert any(
        c.name == "flat src/ layout" and c.status == ps.CheckStatus.WARN for c in group.checks
    )


def test_check_structure_backend_nested_ok(tmp_path, ctx):
    """Backend with nested src/<pkg>/api/v1 → OK (no flat-layout WARN)."""
    _make_backend_repo(tmp_path)
    group = ps.check_structure(ps.ProjectType.BACKEND, ctx)
    assert group.overall() == ps.CheckStatus.OK, (
        f"expected OK, got {group.overall()}: "
        + ", ".join(f"{c.name}={c.status.value}" for c in group.checks)
    )
    assert not any(c.name == "flat src/ layout" for c in group.checks)


def test_check_structure_no_flat_warn_when_nested_pkg(tmp_path, ctx):
    """CLI with src/<pkg>/__init__.py → no flat-layout WARN."""
    (tmp_path / "src").mkdir()
    pkg = tmp_path / "src" / "mycli"
    pkg.mkdir()
    (pkg / "__init__.py").write_text("")
    _write_pyproject(tmp_path, deps=["typer"], scripts={"mycli": "mycli.cli:main"}, name="mycli")
    group = ps.check_structure(ps.ProjectType.CLI, ctx)
    assert not any(c.name == "flat src/ layout" for c in group.checks)


# ── get_repo_full_name with repo arg ─────────────────────────────────────────


def test_get_repo_full_name_with_repo_arg(monkeypatch, tmp_path):
    """get_repo_full_name(repo=Path) uses git -C <repo>."""
    (tmp_path / ".git").mkdir()  # mark as git repo (not actually needed for mock)

    # Override the default GIT_REMOTE_MOCK so only the -C form matches
    def _mock(args: list[str]) -> tuple[int, str, str]:
        if args[:3] == ["git", "-C", str(tmp_path)] and args[3:] == ["remote", "get-url", "origin"]:
            return (0, "https://github.com/slaid098/foo.git\n", "")
        return (1, "", f"unmocked: {args}")

    monkeypatch.setattr(ps, "run_cmd", _mock)
    assert ps.get_repo_full_name(tmp_path) == "slaid098/foo"


def test_get_repo_full_name_no_repo_arg_uses_cwd(monkeypatch):
    """get_repo_full_name() without repo arg → plain git remote (cwd)."""
    monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({}))
    assert ps.get_repo_full_name() == "slaid098/opencode-config"


# ── check_infra with repo_root (git -C) ─────────────────────────────────────


def test_check_infra_repo_root_git_c(monkeypatch, tmp_path, ctx):
    """check_infra(repo_root=...) → git -C <repo_root> for branch protection."""
    for rel in [".github/workflows", ".github"]:
        (tmp_path / rel).mkdir(parents=True, exist_ok=True)
    (tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n")
    (tmp_path / "LICENSE").write_text("MIT\n")

    def _mock(args: list[str]) -> tuple[int, str, str]:
        if args[:3] == ["git", "-C", str(tmp_path)] and args[3:] == ["remote", "get-url", "origin"]:
            return (0, "https://github.com/slaid098/bar.git\n", "")
        if args[:2] == ["gh", "api"] and len(args) >= 3 and "repos/slaid098/bar" in args[2]:
            return (0, '{"rules":[{"type":"pull_request"},{"type":"required_status_checks"}]}', "")
        return (1, "", f"unmocked: {args}")

    monkeypatch.setattr(ps, "run_cmd", _mock)
    group = ps.check_infra(ps.ProjectType.BACKEND, ctx, fast=False, repo_root=tmp_path)
    assert any(
        c.name == "branch protection" and c.status == ps.CheckStatus.OK for c in group.checks
    )


def test_check_infra_repo_root_not_git_repo_warn(monkeypatch, tmp_path, ctx):
    """check_infra(repo_root=non-git) → WARN 'git remote недоступен'."""
    (tmp_path / ".github/workflows").mkdir(parents=True, exist_ok=True)
    (tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n")
    (tmp_path / "LICENSE").write_text("MIT\n")

    def _mock(args: list[str]) -> tuple[int, str, str]:
        if args[:3] == ["git", "-C", str(tmp_path)]:
            return (1, "", "not a git repo")
        return (1, "", f"unmocked: {args}")

    monkeypatch.setattr(ps, "run_cmd", _mock)
    group = ps.check_infra(ps.ProjectType.BACKEND, ctx, fast=False, repo_root=tmp_path)
    assert any(
        c.name == "branch protection" and c.status == ps.CheckStatus.WARN for c in group.checks
    )


# ── RepoCtx (no globals) ────────────────────────────────────────────────────


def test_repo_ctx_is_frozen(tmp_path):
    """RepoCtx is a frozen dataclass — immutable after construction."""
    ctx = ps.RepoCtx(root=tmp_path, config={"route_line_limit": 50})
    with pytest.raises((AttributeError, Exception)):
        ctx.root = tmp_path / "other"  # type: ignore[misc]


def test_repo_ctx_carries_root_and_config(tmp_path):
    """RepoCtx stores root path + config dict verbatim."""
    cfg = {"route_line_limit": 80, "min_test_count": 3}
    ctx = ps.RepoCtx(root=tmp_path, config=cfg)
    assert ctx.root == tmp_path
    assert ctx.config is cfg


def test_path_exists_uses_ctx_root(tmp_path, ctx):
    """path_exists(rel, ctx) checks ``ctx.root / rel``, not module global."""
    (tmp_path / "marker.txt").write_text("x")
    assert ps.path_exists("marker.txt", ctx) is True
    assert ps.path_exists("absent.txt", ctx) is False


def test_read_text_uses_ctx_root(tmp_path, ctx):
    """read_text(rel, ctx) reads from ``ctx.root / rel``."""
    (tmp_path / "f.txt").write_text("hello", encoding="utf-8")
    assert ps.read_text("f.txt", ctx) == "hello"
    assert ps.read_text("missing.txt", ctx) is None


def test_parse_pyproject_uses_ctx_root(tmp_path, ctx):
    """parse_pyproject(ctx) parses ``ctx.root / pyproject.toml``."""
    (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n')
    data = ps.parse_pyproject(ctx)
    assert data.get("project", {}).get("name") == "x"
    empty_ctx = ps.RepoCtx(root=tmp_path / "nope", config={})
    assert ps.parse_pyproject(empty_ctx) == {}


# ── check_pyproject 13 sub-functions (independent) ─────────────────────────


def _full_data(tmp_path, **overrides):
    """Build a fully-valid parsed pyproject dict + write side files."""
    _write_full_pyproject(tmp_path, src_pkg_exists=True, **overrides)
    return ps._load_pyproject(tmp_path)


def test_subfunc_check_build_system_ok(tmp_path, ctx):
    data = _full_data(tmp_path)
    assert ps._check_build_system(data).status == ps.CheckStatus.OK


def test_subfunc_check_build_system_warn(tmp_path, ctx):
    data = _full_data(tmp_path, has_build_system=False)
    assert ps._check_build_system(data).status == ps.CheckStatus.WARN


def test_subfunc_check_hatch_packages_ok_nested(tmp_path, ctx):
    data = _full_data(tmp_path)
    assert (
        ps._check_hatch_packages(data, ctx.root, ps.ProjectType.BACKEND).status == ps.CheckStatus.OK
    )


def test_subfunc_check_hatch_packages_warn_flat_src(tmp_path, ctx):
    (tmp_path / "src").mkdir()
    (tmp_path / "src" / "test_repo").mkdir()
    (tmp_path / "src" / "test_repo" / "__init__.py").write_text("")
    _write_full_pyproject(tmp_path, src_pkg_exists=False, hatch_packages_override=["src"])
    data = ps._load_pyproject(tmp_path)
    res = ps._check_hatch_packages(data, ctx.root, ps.ProjectType.BACKEND)
    assert res.status == ps.CheckStatus.WARN
    assert "deprecated flat layout" in res.detail


def test_subfunc_check_project_fields_ok(tmp_path, ctx):
    data = _full_data(tmp_path)
    project = data.get("project", {})
    assert ps._check_project_fields(project).status == ps.CheckStatus.OK


def test_subfunc_check_project_fields_warn(tmp_path, ctx):
    data = _full_data(tmp_path, has_project_fields=False)
    project = data.get("project", {})
    assert ps._check_project_fields(project).status == ps.CheckStatus.WARN


def test_subfunc_check_ruff_section_ok(tmp_path, ctx):
    data = _full_data(tmp_path)
    assert ps._check_ruff_section(data, ctx.root).status == ps.CheckStatus.OK


def test_subfunc_check_ruff_section_ruff_toml_ok(tmp_path, ctx):
    data = _full_data(tmp_path, has_ruff=False, ruff_toml=True)
    assert ps._check_ruff_section(data, ctx.root).status == ps.CheckStatus.OK


def test_subfunc_check_ruff_section_missing_warn(tmp_path, ctx):
    data = _full_data(tmp_path, has_ruff=False)
    assert ps._check_ruff_section(data, ctx.root).status == ps.CheckStatus.WARN


def test_subfunc_check_mypy_section_strict_ok(tmp_path, ctx):
    data = _full_data(tmp_path)
    assert ps._check_mypy_section(data, ctx.root).status == ps.CheckStatus.OK


def test_subfunc_check_mypy_section_ini_ok(tmp_path, ctx):
    data = _full_data(tmp_path, has_mypy=False, mypy_ini=True)
    assert ps._check_mypy_section(data, ctx.root).status == ps.CheckStatus.OK


def test_subfunc_check_mypy_section_not_strict_warn(tmp_path, ctx):
    data = _full_data(tmp_path, mypy_strict=False)
    assert ps._check_mypy_section(data, ctx.root).status == ps.CheckStatus.WARN


def test_subfunc_check_mypy_section_missing_warn(tmp_path, ctx):
    data = _full_data(tmp_path, has_mypy=False)
    assert ps._check_mypy_section(data, ctx.root).status == ps.CheckStatus.WARN


def test_subfunc_check_pytest_ini_options_ok(tmp_path, ctx):
    data = _full_data(tmp_path)
    tools = data.get("tool", {})
    assert ps._check_pytest_ini_options(tools).status == ps.CheckStatus.OK


def test_subfunc_check_pytest_ini_options_missing_warn(tmp_path, ctx):
    data = _full_data(tmp_path, has_pytest=False)
    tools = data.get("tool", {})
    assert ps._check_pytest_ini_options(tools).status == ps.CheckStatus.WARN


def test_subfunc_check_coverage_run_ok(tmp_path, ctx):
    data = _full_data(tmp_path)
    tools = data.get("tool", {})
    assert ps._check_coverage_run(tools).status == ps.CheckStatus.OK


def test_subfunc_check_coverage_run_warn_no_branch(tmp_path, ctx):
    data = _full_data(tmp_path, cov_branch=False)
    tools = data.get("tool", {})
    assert ps._check_coverage_run(tools).status == ps.CheckStatus.WARN


def test_subfunc_check_coverage_report_ok(tmp_path, ctx):
    data = _full_data(tmp_path)
    tools = data.get("tool", {})
    assert ps._check_coverage_report(tools).status == ps.CheckStatus.OK


def test_subfunc_check_coverage_report_warn(tmp_path, ctx):
    data = _full_data(tmp_path, has_cov_report_exclude=False)
    tools = data.get("tool", {})
    assert ps._check_coverage_report(tools).status == ps.CheckStatus.WARN


def test_subfunc_check_addopts_cov_ok(tmp_path, ctx):
    data = _full_data(tmp_path)
    tools = data.get("tool", {})
    pytest_opts = tools.get("pytest", {}).get("ini_options", {})
    assert ps._check_addopts_cov(pytest_opts).status == ps.CheckStatus.OK


def test_subfunc_check_addopts_cov_warn(tmp_path, ctx):
    data = _full_data(tmp_path, has_cov_fail_under=False)
    tools = data.get("tool", {})
    pytest_opts = tools.get("pytest", {}).get("ini_options", {})
    assert ps._check_addopts_cov(pytest_opts).status == ps.CheckStatus.WARN


def test_subfunc_check_project_status_section_ok(tmp_path, ctx):
    data = _full_data(tmp_path, has_project_status_section=True)
    tools = data.get("tool", {})
    assert ps._check_project_status_section(tools).status == ps.CheckStatus.OK


def test_subfunc_check_project_status_section_warn_default(tmp_path, ctx):
    data = _full_data(tmp_path, has_project_status_section=False)
    tools = data.get("tool", {})
    assert ps._check_project_status_section(tools).status == ps.CheckStatus.WARN


def test_subfunc_check_pre_commit_ok(tmp_path, ctx):
    _full_data(tmp_path)
    assert ps._check_pre_commit(ctx.root).status == ps.CheckStatus.OK


def test_subfunc_check_pre_commit_warn(tmp_path, ctx):
    _full_data(tmp_path, has_pre_commit=False)
    assert ps._check_pre_commit(ctx.root).status == ps.CheckStatus.WARN


def test_subfunc_check_uv_lock_ok(tmp_path, ctx):
    _full_data(tmp_path)
    assert ps._check_uv_lock(ctx.root).status == ps.CheckStatus.OK


def test_subfunc_check_uv_lock_warn(tmp_path, ctx):
    _full_data(tmp_path, has_uv_lock=False)
    assert ps._check_uv_lock(ctx.root).status == ps.CheckStatus.WARN


def test_subfunc_check_python_version_compat_ok(tmp_path, ctx):
    _write_full_pyproject(
        tmp_path, requires_python=">=3.11", python_version_content="3.13\n", src_pkg_exists=True
    )
    data = ps._load_pyproject(tmp_path)
    project = data.get("project", {})
    assert ps._check_python_version_compat(project, ctx.root).status == ps.CheckStatus.OK


def test_subfunc_check_python_version_compat_warn_incompat(tmp_path, ctx):
    _write_full_pyproject(
        tmp_path, requires_python=">=3.11", python_version_content="3.10\n", src_pkg_exists=True
    )
    data = ps._load_pyproject(tmp_path)
    project = data.get("project", {})
    assert ps._check_python_version_compat(project, ctx.root).status == ps.CheckStatus.WARN


def test_subfunc_check_python_version_compat_skip_no_file(tmp_path, ctx):
    _write_full_pyproject(tmp_path, has_python_version=False, src_pkg_exists=True)
    data = ps._load_pyproject(tmp_path)
    project = data.get("project", {})
    res = ps._check_python_version_compat(project, ctx.root)
    assert res.status == ps.CheckStatus.WARN
    assert "skip" in res.detail


def test_pyproject_checks_has_13_entries(tmp_path, ctx):
    """PYPROJECT_CHECKS dispatch table has exactly 13 entries (one per check)."""
    assert len(ps.PYPROJECT_CHECKS) == 13


def test_load_pyproject_missing_returns_none(tmp_path, ctx):
    """_load_pyproject returns None when pyproject.toml absent."""
    assert ps._load_pyproject(tmp_path) is None


def test_load_pyproject_parse_error_sentinel(tmp_path, ctx):
    """_load_pyproject returns dict with __parse_error__ key on bad TOML."""
    (tmp_path / "pyproject.toml").write_text("not valid = = =")
    data = ps._load_pyproject(tmp_path)
    assert data is not None
    assert "__parse_error__" in data
    group = ps.check_pyproject(ps.ProjectType.BACKEND, ctx)
    assert group.overall() == ps.CheckStatus.WARN
    assert any("парсинг" in c.detail for c in group.checks)


# ── scattered-models AST check (issue #251) ─────────────────────────────────


def _make_centralized_models_repo(tmp_path: Path) -> None:
    """Backend repo with all ORM models centralized in ``src/<pkg>/db/models/``.

    Helper for scattered-models tests: produces the GOOD layout (no WARN).
    """
    _make_backend_repo(tmp_path)
    pkg = "test_repo"
    (tmp_path / f"src/{pkg}/db/models/__init__.py").write_text("")
    (tmp_path / f"src/{pkg}/db/models/user.py").write_text(
        "from tortoise import Model, fields\nclass User(Model):\n    name = fields.CharField()\n"
    )


def test_scattered_models_centralized_ok(tmp_path, ctx):
    """All ORM models in ``src/<pkg>/db/models/`` → no scattered-models WARN."""
    _make_centralized_models_repo(tmp_path)
    results = ps._check_scattered_models(ctx, ps.ProjectType.BACKEND)
    assert results == [], f"expected no WARN, got: {results}"


def test_scattered_models_feature_scatter_warn(tmp_path, ctx):
    """``class X(Model)`` in ``src/<pkg>/channels/models.py`` → WARN per file."""
    _make_centralized_models_repo(tmp_path)
    pkg = "test_repo"
    scattered_files = [
        f"src/{pkg}/channels/models.py",
        f"src/{pkg}/monitor/models.py",
        f"src/{pkg}/logs/models.py",
        f"src/{pkg}/models.py",
    ]
    for rel in scattered_files:
        path = tmp_path / rel
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(
            "from tortoise import Model, fields\n"
            "class Thing(Model):\n    name = fields.CharField()\n"
        )
    results = ps._check_scattered_models(ctx, ps.ProjectType.BACKEND)
    assert len(results) == 4, f"expected 4 WARN, got {len(results)}: {results}"
    warned_paths = {r.name for r in results}
    for rel in scattered_files:
        assert rel in warned_paths, f"missing WARN for {rel}"
    assert all(r.status == ps.CheckStatus.WARN for r in results)


def test_scattered_models_in_db_connection_warn(tmp_path, ctx):
    """Model in ``src/<pkg>/db/connection.py`` (inside db/, not models/) → WARN."""
    _make_centralized_models_repo(tmp_path)
    pkg = "test_repo"
    (tmp_path / f"src/{pkg}/db/connection.py").write_text(
        "from tortoise import Model, fields\nclass Internal(Model):\n    x = fields.IntField()\n"
    )
    results = ps._check_scattered_models(ctx, ps.ProjectType.BACKEND)
    assert len(results) == 1, f"expected 1 WARN, got: {results}"
    assert results[0].name == f"src/{pkg}/db/connection.py"
    assert results[0].status == ps.CheckStatus.WARN


def test_scattered_models_in_tests_ok(tmp_path, ctx):
    """Model in ``tests/`` (test model) → OK (skip, tests/ not in src/<pkg>/)."""
    _make_centralized_models_repo(tmp_path)
    (tmp_path / "tests/test_models.py").write_text(
        "from tortoise import Model, fields\n"
        "class TestModel(Model):\n    x = fields.IntField()\n"
        "def test_model(): assert TestModel\n"
    )
    results = ps._check_scattered_models(ctx, ps.ProjectType.BACKEND)
    assert results == [], f"expected no WARN, got: {results}"


def test_scattered_models_flat_layout_skip(tmp_path, ctx):
    """Flat layout (no ``src/<pkg>/``) → skip check, no results."""
    _write_pyproject(tmp_path, deps=["fastapi", "uvicorn"])
    (tmp_path / "src").mkdir()
    (tmp_path / "src/models.py").write_text(
        "from tortoise import Model, fields\nclass Flat(Model):\n    x = fields.IntField()\n"
    )
    results = ps._check_scattered_models(ctx, ps.ProjectType.BACKEND)
    assert results == [], f"flat layout should skip, got: {results}"


def test_scattered_models_cli_skip(tmp_path, ctx):
    """CLI (no db) → skip scattered-models check (returns [])."""
    (tmp_path / "src").mkdir()
    pkg = tmp_path / "src" / "mycli"
    pkg.mkdir()
    (pkg / "__init__.py").write_text("")
    (pkg / "cli.py").write_text("from tortoise import Model\nclass CliModel(Model):\n    pass\n")
    _write_pyproject(tmp_path, deps=["typer"], scripts={"mycli": "mycli.cli:main"}, name="mycli")
    results = ps._check_scattered_models(ctx, ps.ProjectType.CLI)
    assert results == [], f"CLI should skip, got: {results}"


def test_scattered_models_no_tortoise_import_skip(tmp_path, ctx):
    """File with ``class X(Model)`` but no tortoise import → skip (false positive guard)."""
    _make_centralized_models_repo(tmp_path)
    pkg = "test_repo"
    (tmp_path / f"src/{pkg}/services/other.py").write_text(
        "# class from another lib defining its own Model\n"
        "from some_other_lib import Model\n"
        "class Other(Model):\n    pass\n"
    )
    results = ps._check_scattered_models(ctx, ps.ProjectType.BACKEND)
    assert results == [], f"non-tortoise Model should not WARN, got: {results}"


def test_scattered_models_via_import_from_models_module(tmp_path, ctx):
    """``class X(Model)`` where ``Model`` comes from own ``db.models`` → WARN."""
    _make_centralized_models_repo(tmp_path)
    pkg = "test_repo"
    (tmp_path / f"src/{pkg}/services/user_service.py").write_text(
        f"from {pkg}.db.models import Model\nclass ServiceModel(Model):\n    pass\n"
    )
    results = ps._check_scattered_models(ctx, ps.ProjectType.BACKEND)
    assert len(results) == 1, f"expected 1 WARN, got: {results}"
    assert results[0].name == f"src/{pkg}/services/user_service.py"
    assert results[0].status == ps.CheckStatus.WARN


def test_scattered_models_check_structure_integration(tmp_path, ctx):
    """``check_structure`` for BACKEND includes scattered-models WARNs."""
    _make_centralized_models_repo(tmp_path)
    pkg = "test_repo"
    (tmp_path / f"src/{pkg}/channels").mkdir(parents=True, exist_ok=True)
    (tmp_path / f"src/{pkg}/channels/models.py").write_text(
        "from tortoise import Model, fields\nclass Chan(Model):\n    name = fields.CharField()\n"
    )
    group = ps.check_structure(ps.ProjectType.BACKEND, ctx)
    scattered_warns = [
        c
        for c in group.checks
        if "channels/models.py" in c.name and c.status == ps.CheckStatus.WARN
    ]
    assert scattered_warns, "expected scattered WARN in check_structure, got: " + ", ".join(
        f"{c.name}={c.status.value}" for c in group.checks
    )


def test_scattered_models_no_pyproject_skip(tmp_path, ctx):
    """No pyproject.toml (cannot resolve package name) → skip, no results."""
    (tmp_path / "src").mkdir()
    (tmp_path / "src/whatever.py").write_text(
        "from tortoise import Model\nclass X(Model):\n    pass\n"
    )
    results = ps._check_scattered_models(ctx, ps.ProjectType.BACKEND)
    assert results == [], f"no pyproject should skip, got: {results}"


# ── issue #266: frontend stack detection (fullstack) ────────────────────────


def test_fullstack_frontend_stack_ok(tmp_path, ctx):
    """All 4 frontend markers present (tailwindcss + bits-ui + components.json
    + tsconfig.json) → OK."""
    _make_fullstack_repo(tmp_path)
    group = ps.check_structure(ps.ProjectType.FULLSTACK, ctx)
    frontend_checks = [c for c in group.checks if c.name == "frontend stack"]
    assert frontend_checks, f"expected 'frontend stack' check, got: {group.checks}"
    assert frontend_checks[0].status == ps.CheckStatus.OK, (
        f"expected OK, got {frontend_checks[0].status}: {frontend_checks[0].detail}"
    )


def test_fullstack_frontend_stack_missing_tailwind(tmp_path, ctx):
    """package.json without ``tailwindcss`` dep → WARN."""
    _make_fullstack_repo(tmp_path)
    pkg = tmp_path / "frontend" / "package.json"
    pkg.write_text('{"name": "test-frontend", "dependencies": {"bits-ui": "^1.0.0"}}\n')
    group = ps.check_structure(ps.ProjectType.FULLSTACK, ctx)
    frontend = [c for c in group.checks if c.name == "frontend stack"]
    assert frontend and frontend[0].status == ps.CheckStatus.WARN
    assert "tailwindcss" in frontend[0].detail


def test_fullstack_frontend_stack_missing_shadcn(tmp_path, ctx):
    """package.json without ``bits-ui`` dep (shadcn-svelte proxy) → WARN."""
    _make_fullstack_repo(tmp_path)
    pkg = tmp_path / "frontend" / "package.json"
    pkg.write_text('{"name": "test-frontend", "dependencies": {"tailwindcss": "^4.0.0"}}\n')
    group = ps.check_structure(ps.ProjectType.FULLSTACK, ctx)
    frontend = [c for c in group.checks if c.name == "frontend stack"]
    assert frontend and frontend[0].status == ps.CheckStatus.WARN
    assert "bits-ui" in frontend[0].detail


def test_fullstack_frontend_stack_missing_components_json(tmp_path, ctx):
    """Missing ``frontend/components.json`` → WARN."""
    _make_fullstack_repo(tmp_path)
    (tmp_path / "frontend" / "components.json").unlink()
    group = ps.check_structure(ps.ProjectType.FULLSTACK, ctx)
    frontend = [c for c in group.checks if c.name == "frontend stack"]
    assert frontend and frontend[0].status == ps.CheckStatus.WARN
    assert "components.json" in frontend[0].detail


def test_fullstack_frontend_stack_missing_tsconfig(tmp_path, ctx):
    """Missing ``frontend/tsconfig.json`` → WARN."""
    _make_fullstack_repo(tmp_path)
    (tmp_path / "frontend" / "tsconfig.json").unlink()
    group = ps.check_structure(ps.ProjectType.FULLSTACK, ctx)
    frontend = [c for c in group.checks if c.name == "frontend stack"]
    assert frontend and frontend[0].status == ps.CheckStatus.WARN
    assert "tsconfig.json" in frontend[0].detail


# ── issue #278: mobile-first guarantee (fullstack only) ─────────────────────


def test_fullstack_mobile_first_ok(tmp_path, ctx):
    """All mobile-first markers present (manifest, mobile.spec.ts, a11y.spec.ts,
    app.html viewport+manifest, @axe-core/playwright) → OK."""
    _make_fullstack_repo(tmp_path)
    group = ps.check_structure(ps.ProjectType.FULLSTACK, ctx)
    mobile = [c for c in group.checks if c.name == "mobile-first"]
    assert mobile, f"expected 'mobile-first' check, got: {group.checks}"
    assert mobile[0].status == ps.CheckStatus.OK, (
        f"expected OK, got {mobile[0].status}: {mobile[0].detail}"
    )


def test_fullstack_mobile_first_warns_missing_manifest(tmp_path, ctx):
    """Missing ``frontend/static/manifest.webmanifest`` → WARN (non-blocking)."""
    _make_fullstack_repo(tmp_path)
    (tmp_path / "frontend" / "static" / "manifest.webmanifest").unlink()
    group = ps.check_structure(ps.ProjectType.FULLSTACK, ctx)
    mobile = [c for c in group.checks if c.name == "mobile-first"]
    assert mobile and mobile[0].status == ps.CheckStatus.WARN, (
        f"expected WARN for missing manifest, got: {mobile}"
    )
    assert "manifest.webmanifest" in mobile[0].detail


def test_fullstack_mobile_first_skips_backend(tmp_path, ctx):
    """Backend project type → ``_check_mobile_first`` not invoked (skip).

    Mobile-first is a FULLSTACK-only guarantee; the structure group for
    backend must not contain a "mobile-first" check.
    """
    _make_backend_repo(tmp_path)
    group = ps.check_structure(ps.ProjectType.BACKEND, ctx)
    mobile = [c for c in group.checks if c.name == "mobile-first"]
    assert not mobile, f"backend must skip mobile-first, got: {mobile}"


# ── issue #274: db/models auto-detect from deps ──────────────────────────────


def test_backend_db_project_missing_db_models_warn(tmp_path, ctx):
    """Backend WITH a db dep (tortoise-orm) but no ``db/models`` dir → WARN.

    The db-project is auto-detected from ``[project.dependencies]``; a
    missing ``db/models`` is a WARN (not FAIL — issue #275: all-WARN
    contract). Issue #274 removed the ``no_db`` config marker in favor of
    dependency-based auto-detection.
    """
    pkg = "test_repo"
    for rel in [
        f"src/{pkg}/api/v1",
        f"src/{pkg}/schemas",
        f"src/{pkg}/services",
        f"src/{pkg}/config",
    ]:
        (tmp_path / rel).mkdir(parents=True, exist_ok=True)
    (tmp_path / "src" / pkg / "__init__.py").write_text("")
    (tmp_path / f"src/{pkg}/config/settings.py").write_text("settings = {}\n")
    (tmp_path / "main.py").write_text(
        "from contextlib import asynccontextmanager\n"
        "@asynccontextmanager\nasync def lifespan(app): yield\n"
    )
    # db dep present → _is_db_project True → db/models required (WARN if absent)
    _write_pyproject(tmp_path, deps=["fastapi", "uvicorn", "tortoise-orm"], cov_source=["src"])
    group = ps.check_structure(ps.ProjectType.BACKEND, ctx)
    db_models = [c for c in group.checks if "db/models" in c.name]
    assert db_models, f"expected db/models check, got: {group.checks}"
    assert db_models[0].status == ps.CheckStatus.WARN, (
        f"expected WARN for missing db/models in db-project, got {db_models[0].status}"
    )


def test_backend_no_db_project_skips_db_models(tmp_path, ctx):
    """Backend WITHOUT a db dep (cookiecutter use_db=no) → db/models skipped.

    No db marker in deps → ``_is_db_project`` returns False → ``db/models``
    is NOT in the expected paths. No FAIL/WARN even if the dir is absent.
    """
    pkg = "test_repo"
    for rel in [
        f"src/{pkg}/api/v1",
        f"src/{pkg}/schemas",
        f"src/{pkg}/services",
        f"src/{pkg}/config",
    ]:
        (tmp_path / rel).mkdir(parents=True, exist_ok=True)
    (tmp_path / "src" / pkg / "__init__.py").write_text("")
    (tmp_path / f"src/{pkg}/config/settings.py").write_text("settings = {}\n")
    (tmp_path / "main.py").write_text(
        "from contextlib import asynccontextmanager\n"
        "@asynccontextmanager\nasync def lifespan(app): yield\n"
    )
    # use_db=no → deps do NOT include a db marker
    _write_pyproject(tmp_path, deps=["fastapi", "uvicorn"], cov_source=["src"])
    group = ps.check_structure(ps.ProjectType.BACKEND, ctx)
    db_models_checks = [c for c in group.checks if "db/models" in c.name]
    assert not db_models_checks, f"db/models should be skipped (no db dep), got: {db_models_checks}"


def test_fullstack_db_project_missing_db_models_warn(tmp_path, ctx):
    """Fullstack WITH a db dep but no ``backend/src/<pkg>/db/models`` → WARN."""
    _make_fullstack_repo(tmp_path)
    # _make_fullstack_repo pyproject has fastapi/uvicorn; add a db dep.
    pyproject = tmp_path / "backend" / "pyproject.toml"
    content = pyproject.read_text()
    old_deps = '"fastapi",\n    "uvicorn"'
    new_deps = '"fastapi",\n    "uvicorn",\n    "tortoise-orm"'
    pyproject.write_text(content.replace(old_deps, new_deps))
    shutil.rmtree(tmp_path / "backend" / "src" / "test_repo" / "db")
    group = ps.check_structure(ps.ProjectType.FULLSTACK, ctx)
    db_models = [c for c in group.checks if "db/models" in c.name]
    assert db_models, f"expected db/models check for fullstack, got: {group.checks}"
    assert db_models[0].status == ps.CheckStatus.WARN, (
        f"expected WARN for missing db/models in fullstack db-project, got {db_models[0].status}"
    )


def test_detect_backend_use_db_no(tmp_path, ctx):
    """Backend with use_db=no (no db dep) still detects as BACKEND, not UNKNOWN.

    Issue #274: ``_matches_backend`` no longer requires ``db/models`` — only
    ``fastapi``/``uvicorn`` + ``src/<pkg>/api/v1``. A cookiecutter
    ``use_db=no`` repo (no tortoise-orm/asyncpg) is still BACKEND.
    """
    pkg = "test_repo"
    for rel in [
        f"src/{pkg}/api/v1",
        f"src/{pkg}/schemas",
        f"src/{pkg}/services",
        f"src/{pkg}/config",
    ]:
        (tmp_path / rel).mkdir(parents=True, exist_ok=True)
    (tmp_path / "src" / pkg / "__init__.py").write_text("")
    (tmp_path / f"src/{pkg}/config/settings.py").write_text("settings = {}\n")
    (tmp_path / "main.py").write_text(
        "from contextlib import asynccontextmanager\n"
        "@asynccontextmanager\nasync def lifespan(app): yield\n"
    )
    # use_db=no → deps do NOT include tortoise-orm/asyncpg
    _write_pyproject(tmp_path, deps=["fastapi", "uvicorn"])
    assert ps.detect_project_type(ctx) == ps.ProjectType.BACKEND, (
        "use_db=no backend must detect as BACKEND (db/models not required for type detection)"
    )


# ── issue #275: all-WARN non-blocking contract ──────────────────────────────


def test_all_checks_are_warn(tmp_path, ctx):
    """No check-function returns FAIL — every problem is WARN (issue #275).

    Builds a backend repo missing several expected pieces (no tests dir,
    missing ruff/mypy sections, missing README) and asserts that across all
    8 groups no CheckResult carries ``CheckStatus.FAIL``. ``FAIL`` is kept
    in the enum for backward compatibility (tests import it, externally
    constructed results roll up to FAIL), but no check-function emits it.
    """
    # minimal pyproject with fastapi dep but NO ruff/mypy/pytest sections
    _write_pyproject(tmp_path, deps=["fastapi", "uvicorn"], has_ruff=False, has_mypy=False)
    (tmp_path / "main.py").write_text(
        "from contextlib import asynccontextmanager\n"
        "@asynccontextmanager\nasync def lifespan(app): yield\n"
    )
    pkg = "test_repo"
    for rel in [
        f"src/{pkg}/api/v1",
        f"src/{pkg}/schemas",
        f"src/{pkg}/services",
        f"src/{pkg}/config",
    ]:
        (tmp_path / rel).mkdir(parents=True, exist_ok=True)
    (tmp_path / "src" / pkg / "__init__.py").write_text("")
    (tmp_path / f"src/{pkg}/config/settings.py").write_text("settings = {}\n")
    monkeypatch = pytest.MonkeyPatch()
    monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("gh", "api"): (1, "", "no auth")}))
    try:
        groups = ps.run_all_checks(ps.ProjectType.BACKEND, ctx, fast=True)
    finally:
        monkeypatch.undo()
    failures = [
        (g.name, c.name, c.status.value)
        for g in groups
        for c in g.checks
        if c.status == ps.CheckStatus.FAIL
    ]
    assert not failures, f"issue #275 violation — FAIL checks found: {failures}"


def test_exit_code_always_zero(monkeypatch, tmp_path, capsys):
    """``main()`` exits 0 even when the repo has problems (issue #275).

    A repo with no pyproject, no tests, no README → many WARN, but exit code
    is 0 (informational mode). ``--check`` is accepted but no longer forces 1.
    """
    monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("gh", "api"): (1, "", "no auth")}))
    monkeypatch.setattr("sys.argv", ["project-status.py"])
    with pytest.raises(SystemExit) as exc:
        ps.main()
    assert exc.value.code == 0
    captured = capsys.readouterr()
    assert "WARN" in captured.out
    # Итог line present and shows OK/WARN only (no FAIL — issue #275)
    assert "Итог:" in captured.out
    assert "WARN: " in captured.out
    assert "FAIL:" not in captured.out
