"""Tests for the cookiecutter templates under ``.opencode/templates/``.

Covers:
- All three templates exist with the expected top-level shape
- ``cookiecutter.json`` exposes the required variables
- Conditional files are rendered (use_auth=yes/no, use_db=yes/no)
- Generated projects conform to the ``project-status`` oracle contract:
  expected dirs, lifespan in main.py, ruff/mypy/pytest in pyproject.toml,
  README delimiter tags, ci.yml, LICENSE.
- Backend ``user_service.get_users`` returns a list (bug fix).
- IP whitelist defaults to ``["127.0.0.1", "::1"]`` (no hardcoded prod IPs).
- Metadata lives only in ``utils/metadata.py`` (no duplication in __init__).
"""

from __future__ import annotations

import ast
import importlib.util
import json
import re
import sys
from pathlib import Path

import pytest
from cookiecutter.exceptions import FailedHookException
from cookiecutter.main import cookiecutter

REPO_ROOT = Path(__file__).resolve().parent.parent
TEMPLATES_DIR = REPO_ROOT / ".opencode" / "templates"

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


# ── fixtures ─────────────────────────────────────────────────────────────────


@pytest.fixture
def render(tmp_path, template_name, extra_context):
    """Render a cookiecutter template into ``tmp_path`` and return the root."""
    out_dir = cookiecutter(
        template=str(TEMPLATES_DIR / template_name),
        no_input=True,
        output_dir=str(tmp_path),
        extra_context=extra_context,
    )
    return Path(out_dir)


# ── template presence ───────────────────────────────────────────────────────


@pytest.mark.parametrize(
    "template_name, extra_context",
    [
        ("backend", {"project_name": "demo_be"}),
        ("cli", {"project_name": "demo_cli"}),
        ("fullstack", {"project_name": "demo_fs"}),
    ],
)
def test_template_dir_exists(render):
    """Each template renders to a project dir of the given name."""
    assert render.is_dir()
    assert render.name in {"demo_be", "demo_cli", "demo_fs"}


@pytest.mark.parametrize("template_name", ["backend", "cli", "fullstack"])
def test_cookiecutter_json_has_required_keys(template_name):
    """``cookiecutter.json`` must expose the 6 required variables."""
    cfg = json.loads((TEMPLATES_DIR / template_name / "cookiecutter.json").read_text())
    required = {
        "project_name",
        "project_type",
        "description",
        "use_auth",
        "use_db",
        "python_version",
    }
    assert required.issubset(cfg.keys()), f"missing: {required - set(cfg.keys())}"


@pytest.mark.parametrize("template_name, extra_context", [("backend", {"project_name": "be"})])
def test_python_version_synced(render):
    """``requires-python`` in pyproject must match ``.python-version``."""
    pv = (render / ".python-version").read_text().strip()
    pyproject = (render / "pyproject.toml").read_text()
    assert f">={pv}" in pyproject, f"requires-python must be >={pv}"


# ── backend template structure ───────────────────────────────────────────────


@pytest.mark.parametrize(
    "template_name, extra_context",
    [("backend", {"project_name": "be", "use_db": "yes", "use_auth": "yes"})],
)
def test_backend_structure_full(render):
    """Backend with use_db=yes + use_auth=yes has the full tree."""
    expected_files = [
        "pyproject.toml",
        ".python-version",
        "env.example",
        ".gitignore",
        "LICENSE",
        ".pre-commit-config.yaml",
        ".github/workflows/ci.yml",
        ".github/dependabot.yml",
        "main.py",
        "migrations/README.md",
        "src/be/__init__.py",
        "src/be/api/router.py",
        "src/be/api/v1/router.py",
        "src/be/api/v1/dependencies.py",
        "src/be/api/v1/routes/users.py",
        "src/be/api/v1/routes/auth.py",
        "src/be/config/settings.py",
        "src/be/config/logger.py",
        "src/be/db/connection.py",
        "src/be/db/models/user.py",
        "src/be/schemas/base.py",
        "src/be/schemas/user.py",
        "src/be/services/user_service.py",
        "src/be/services/auth_service.py",
        "src/be/utils/metadata.py",
        "tests/conftest.py",
        "tests/unit/test_user_service.py",
        "tests/unit/test_user.py",
        "tests/api/test_users.py",
        "tests/integration/test_real_external.py",
        "tests/test_auth.py",
    ]
    for rel in expected_files:
        assert (render / rel).exists(), f"missing: {rel}"


@pytest.mark.parametrize(
    "template_name, extra_context",
    [("backend", {"project_name": "be", "use_db": "no", "use_auth": "no"})],
)
def test_backend_structure_no_db_no_auth(render):
    """use_db=no + use_auth=no strips db/, migrations/, auth files, and the
    db-dependent files (user_service, users routes, dependencies, schemas/user,
    test_user*). The previous wiring left unconditional ``from ...db.models
    import User`` imports behind, which broke startup (ImportError/NameError).
    """
    assert not (render / "src/be/db").exists()
    assert not (render / "migrations").exists()
    assert not (render / "src/be/api/v1/routes/auth.py").exists()
    assert not (render / "src/be/services/auth_service.py").exists()
    assert not (render / "tests/test_auth.py").exists()
    # db-dependent files must be stripped too (root cause of broken start)
    assert not (render / "src/be/api/v1/routes/users.py").exists()
    assert not (render / "src/be/services/user_service.py").exists()
    assert not (render / "src/be/api/v1/dependencies.py").exists()
    assert not (render / "src/be/schemas/user.py").exists()
    assert not (render / "tests/unit/test_user.py").exists()
    assert not (render / "tests/unit/test_user_service.py").exists()
    assert not (render / "tests/api/test_users.py").exists()
    # core backend structure still present
    assert (render / "src/be/api/v1/router.py").exists()
    assert (render / "main.py").exists()
    assert (render / "src/be/config/settings.py").exists()


@pytest.mark.parametrize(
    "template_name, extra_context",
    [("backend", {"project_name": "be", "use_db": "yes", "use_auth": "no"})],
)
def test_backend_use_db_yes_use_auth_no(render):
    """use_db=yes keeps db + migrations; use_auth=no drops auth files."""
    assert (render / "src/be/db/connection.py").exists()
    assert (render / "migrations").exists()
    assert not (render / "src/be/api/v1/routes/auth.py").exists()
    assert not (render / "src/be/services/auth_service.py").exists()
    # user model without hashed_password field (use_auth=no) — the docstring
    # still mentions the field name, so check the actual field declaration.
    user_model = (render / "src/be/db/models/user.py").read_text()
    assert "hashed_password = fields" not in user_model


@pytest.mark.parametrize(
    "template_name, extra_context",
    [("backend", {"project_name": "be", "use_db": "yes", "use_auth": "yes"})],
)
def test_backend_use_auth_yes_has_hashed_password(render):
    """use_auth=yes adds hashed_password to the user model."""
    user_model = (render / "src/be/db/models/user.py").read_text()
    assert "hashed_password" in user_model


# ── cli template structure ───────────────────────────────────────────────────


@pytest.mark.parametrize("template_name, extra_context", [("cli", {"project_name": "cl"})])
def test_cli_structure(render):
    """CLI template has the entry point + cli.py + core.py + tests."""
    expected = [
        "pyproject.toml",
        ".python-version",
        "LICENSE",
        ".github/workflows/ci.yml",
        "src/cl/__init__.py",
        "src/cl/__main__.py",
        "src/cl/cli.py",
        "src/cl/core.py",
        "tests/conftest.py",
        "tests/test_cli.py",
        "tests/test_core.py",
    ]
    for rel in expected:
        assert (render / rel).exists(), f"missing: {rel}"


@pytest.mark.parametrize("template_name, extra_context", [("cli", {"project_name": "cl"})])
def test_cli_has_scripts_entry(render):
    """``[project.scripts]`` must wire the entry point to __main__:app."""
    pyproject = (render / "pyproject.toml").read_text()
    assert "[project.scripts]" in pyproject
    assert 'cl = "cl.__main__:app"' in pyproject


# ── fullstack template structure ─────────────────────────────────────────────


@pytest.mark.parametrize(
    "template_name, extra_context",
    [("fullstack", {"project_name": "fs", "use_db": "yes", "use_auth": "yes"})],
)
def test_fullstack_structure(render):
    """Fullstack has backend/ + frontend/ with the expected files."""
    assert (render / "backend").is_dir()
    assert (render / "frontend").is_dir()
    # backend mirrors the backend template
    assert (render / "backend/pyproject.toml").exists()
    assert (render / "backend/main.py").exists()
    assert (render / "backend/src/fs/api/v1/routes/users.py").exists()
    assert (render / "backend/src/fs/services/auth_service.py").exists()
    assert (render / "backend/migrations").is_dir()
    # frontend has the SvelteKit + Tailwind v4 + shadcn-svelte stack (TS)
    assert (render / "frontend/package.json").exists()
    assert (render / "frontend/svelte.config.js").exists()
    assert (render / "frontend/vite.config.ts").exists()
    assert (render / "frontend/vitest.config.ts").exists()
    assert (render / "frontend/biome.json").exists()
    assert (render / "frontend/knip.json").exists()
    assert (render / "frontend/tsconfig.json").exists()
    assert (render / "frontend/components.json").exists()
    assert (render / "frontend/src/app.html").exists()
    assert (render / "frontend/src/app.css").exists()
    assert (render / "frontend/src/hooks.server.js").exists()
    assert (render / "frontend/src/routes/+layout.svelte").exists()
    assert (render / "frontend/src/routes/+page.svelte").exists()
    assert (render / "frontend/src/routes/+page.ts").exists()
    assert (render / "frontend/src/lib/components/app-sidebar.svelte").exists()
    assert (render / "frontend/src/lib/utils/cn.svelte.ts").exists()
    assert (render / "frontend/src/lib/hooks/is-mobile.svelte.ts").exists()
    assert (render / "frontend/tests/e2e/app.spec.ts").exists()
    # issue #278: mobile-first — PWA manifest + SVG icon + mobile + a11y specs
    assert (render / "frontend/static/manifest.webmanifest").exists()
    assert (render / "frontend/static/icon.svg").exists()
    assert (render / "frontend/tests/e2e/mobile.spec.ts").exists()
    assert (render / "frontend/tests/e2e/accessibility.spec.ts").exists()
    # removed: counter.svelte.js, Header.svelte, jsconfig.json, +page.js
    # root CI runs both
    assert (render / ".github/workflows/ci.yml").exists()


@pytest.mark.parametrize(
    "template_name, extra_context",
    [("fullstack", {"project_name": "fs", "use_db": "no", "use_auth": "no"})],
)
def test_fullstack_conditional_stripped(render):
    """use_db=no + use_auth=no strip auth/db from the backend subtree.

    Also strips the db-dependent files (users routes, user_service,
    dependencies, schemas/user, test_user*) — the broken-conditional fix.
    """
    assert not (render / "backend/src/fs/db").exists()
    assert not (render / "backend/migrations").exists()
    assert not (render / "backend/src/fs/api/v1/routes/auth.py").exists()
    assert not (render / "backend/src/fs/services/auth_service.py").exists()
    # db-dependent files stripped too
    assert not (render / "backend/src/fs/api/v1/routes/users.py").exists()
    assert not (render / "backend/src/fs/services/user_service.py").exists()
    assert not (render / "backend/src/fs/api/v1/dependencies.py").exists()
    assert not (render / "backend/src/fs/schemas/user.py").exists()


@pytest.mark.parametrize("template_name, extra_context", [("fullstack", {"project_name": "fs"})])
def test_fullstack_frontend_uses_svelte5(render):
    """package.json pins svelte 5 (in dependencies or devDependencies)."""
    pkg = json.loads((render / "frontend/package.json").read_text())
    deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
    svelte_dep = deps.get("svelte", "")
    assert svelte_dep.startswith("^5.") or svelte_dep.startswith("5"), svelte_dep


@pytest.mark.parametrize("template_name, extra_context", [("fullstack", {"project_name": "fs"})])
def test_fullstack_frontend_no_isomorphic_fetch(render):
    """package.json must not depend on isomorphic-fetch (SvelteKit has native fetch)."""
    pkg = json.loads((render / "frontend/package.json").read_text())
    deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
    assert "isomorphic-fetch" not in deps, "isomorphic-fetch is unused in SvelteKit"


@pytest.mark.parametrize("template_name, extra_context", [("fullstack", {"project_name": "fs"})])
def test_fullstack_error_page_is_fragment(render):
    """+error.svelte must be a SvelteKit fragment, not a full HTML document."""
    error_page = (render / "frontend/src/routes/+error.svelte").read_text()
    assert "<!DOCTYPE html" not in error_page, "+error.svelte must not be a full HTML document"
    assert "<html" not in error_page, "+error.svelte must not wrap in <html>"


@pytest.mark.parametrize("template_name, extra_context", [("fullstack", {"project_name": "fs"})])
def test_fullstack_page_uses_data_title(render):
    """+page.svelte must consume ``data.title`` from the load function."""
    page_svelte = (render / "frontend/src/routes/+page.svelte").read_text()
    page_ts = (render / "frontend/src/routes/+page.ts").read_text()
    # load returns { title: ... }
    assert "title" in page_ts
    # +page.svelte consumes data.title (not a hardcoded cookiecutter literal)
    assert "data.title" in page_svelte, "+page.svelte must use data.title from load"


@pytest.mark.parametrize("template_name, extra_context", [("fullstack", {"project_name": "fs"})])
def test_fullstack_hooks_no_unused_redirect(render):
    """hooks.server.js must not import redirect (unused per biome)."""
    hooks = (render / "frontend/src/hooks.server.js").read_text()
    assert "redirect" not in hooks, "hooks.server.js must not import unused redirect"


@pytest.mark.parametrize("template_name, extra_context", [("fullstack", {"project_name": "fs"})])
def test_fullstack_has_tailwind_config(render):
    """vite.config.ts wires @tailwindcss/vite; package.json pins tailwindcss."""
    vite = (render / "frontend/vite.config.ts").read_text()
    assert "tailwindcss" in vite, "vite.config.ts must wire @tailwindcss/vite"
    # tailwindcss() must run BEFORE sveltekit() (order matters)
    assert vite.index("tailwindcss") < vite.index("sveltekit")
    pkg = json.loads((render / "frontend/package.json").read_text())
    deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
    assert "tailwindcss" in deps, "package.json must depend on tailwindcss"
    assert "@tailwindcss/vite" in deps, "package.json must depend on @tailwindcss/vite"


@pytest.mark.parametrize("template_name, extra_context", [("fullstack", {"project_name": "fs"})])
def test_fullstack_has_shadcn_components_json(render):
    """components.json configures shadcn-svelte (new-york, neutral, TS)."""
    cfg_path = render / "frontend/components.json"
    assert cfg_path.exists(), "components.json must exist"
    cfg = json.loads(cfg_path.read_text())
    assert cfg["style"] == "new-york", f"style must be new-york, got {cfg.get('style')}"
    assert cfg["tailwind"]["baseColor"] == "neutral"
    assert cfg["typescript"] is True, "typescript must be true"
    aliases = cfg["aliases"]
    assert aliases["lib"] == "$lib"
    assert aliases["utils"] == "$lib/utils"
    assert aliases["ui"] == "$lib/components/ui"
    assert aliases["hooks"] == "$lib/hooks"


@pytest.mark.parametrize("template_name, extra_context", [("fullstack", {"project_name": "fs"})])
def test_fullstack_layout_has_sidebar_provider(render):
    """+layout.svelte wires the sidebar around the content (directly or via
    AppSidebar which embeds Sidebar.Provider)."""
    layout = (render / "frontend/src/routes/+layout.svelte").read_text()
    has_provider = "Sidebar.Provider" in layout or "SidebarProvider" in layout
    has_app_sidebar = "AppSidebar" in layout
    assert has_provider or has_app_sidebar, (
        "+layout.svelte must wrap content in Sidebar.Provider (directly or via AppSidebar)"
    )
    # AppSidebar itself must contain the Sidebar.Provider
    app_sidebar = (render / "frontend/src/lib/components/app-sidebar.svelte").read_text()
    assert "Sidebar.Provider" in app_sidebar or "SidebarProvider" in app_sidebar, (
        "app-sidebar.svelte must embed Sidebar.Provider"
    )


@pytest.mark.parametrize("template_name, extra_context", [("fullstack", {"project_name": "fs"})])
def test_fullstack_uses_typescript(render):
    """Frontend is TypeScript: tsconfig.json, no jsconfig.json, +page.ts, lang=ts."""
    assert (render / "frontend/tsconfig.json").exists(), "tsconfig.json must exist"
    assert not (render / "frontend/jsconfig.json").exists(), "jsconfig.json must be removed"
    assert (render / "frontend/src/routes/+page.ts").exists(), "+page.ts must exist"
    assert not (render / "frontend/src/routes/+page.js").exists(), "+page.js must be removed"
    page_svelte = (render / "frontend/src/routes/+page.svelte").read_text()
    assert 'lang="ts"' in page_svelte, '+page.svelte must use <script lang="ts">'
    # @tanstack/table-core pinned to ^8 (NOT ^9 — shadcn-svelte pins v8)
    pkg = json.loads((render / "frontend/package.json").read_text())
    deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
    table_core = deps.get("@tanstack/table-core", "")
    assert table_core.startswith("^8."), (
        f"@tanstack/table-core must be ^8.x (shadcn-svelte pin), got {table_core}"
    )
    # @lucide/svelte (NOT deprecated lucide-svelte)
    assert "@lucide/svelte" in deps, "must use @lucide/svelte (not lucide-svelte)"
    assert "lucide-svelte" not in deps, "lucide-svelte is deprecated"
    # tw-animate-css (NOT tw-animate)
    assert "tw-animate-css" in deps, "must use tw-animate-css (not tw-animate)"
    assert "tw-animate" not in deps or "tw-animate-css" in deps


# ── pyproject.toml completeness ──────────────────────────────────────────────


@pytest.mark.parametrize(
    "template_name, extra_context",
    [("backend", {"project_name": "be"}), ("cli", {"project_name": "cl"})],
)
def test_pyproject_has_required_sections(render):
    """pyproject.toml must have build-system + ruff + mypy + pytest + project-status."""
    content = (render / "pyproject.toml").read_text()
    required = [
        "[build-system]",
        "hatchling",
        "[tool.ruff]",
        "[tool.ruff.lint]",
        "[tool.mypy]",
        "strict = true",
        "[tool.pytest.ini_options]",
        "[tool.project-status]",
    ]
    for section in required:
        assert section in content, f"missing section: {section}"
    assert 'readme = "README.md"' not in content, (
        "README.md is absent (issue #269) — readme field must not reference it"
    )


@pytest.mark.parametrize("template_name, extra_context", [("backend", {"project_name": "be"})])
def test_backend_pytest_asyncio_auto(render):
    """Backend pytest must use asyncio_mode=auto (no @pytest.mark.asyncio)."""
    pyproject = (render / "pyproject.toml").read_text()
    assert 'asyncio_mode = "auto"' in pyproject


@pytest.mark.parametrize(
    "template_name, extra_context",
    [
        ("backend", {"project_name": "be"}),
        ("cli", {"project_name": "cl"}),
        ("fullstack", {"project_name": "fs", "use_db": "yes"}),
    ],
)
def test_pyproject_no_readme_field(render, template_name):
    """pyproject must not reference README.md (issue #269 — README is absent).

    hatchling fails the build when ``readme = "README.md"`` points to a
    missing file, so the field must be dropped entirely.
    """
    if template_name == "fullstack":
        content = (render / "backend" / "pyproject.toml").read_text()
    else:
        content = (render / "pyproject.toml").read_text()
    assert 'readme = "README.md"' not in content, (
        "README.md is absent (issue #269) — readme field must not reference it"
    )


# ── nested src/<package>/ layout (issue #241) ──────────────────────────────────


@pytest.mark.parametrize(
    "template_name, extra_context",
    [
        ("backend", {"project_name": "be"}),
        ("cli", {"project_name": "cl"}),
    ],
)
def test_pyproject_hatch_packages_nested(render):
    """Hatch packages must be ``["src/<package>"]`` (NOT ``["src"]``).

    Issue #241: ``packages = ["src"]`` (flat convention) is deprecated —
    only ``packages = ["src/<package>"]`` is valid for a publishable,
    reusable-as-git-dep package.
    """
    pyproject = (render / "pyproject.toml").read_text()
    pkg = extra_context_value(render, "project_name")
    assert f'packages = ["src/{pkg}"]' in pyproject, (
        f"expected packages=['src/{pkg}'], got flat or wrong packages"
    )
    assert 'packages = ["src"]' not in pyproject, "flat packages=['src'] is deprecated"


@pytest.mark.parametrize(
    "template_name, extra_context",
    [("fullstack", {"project_name": "fs", "use_db": "yes"})],
)
def test_pyproject_hatch_packages_nested_fullstack(render):
    """Fullstack backend pyproject must use nested packages (issue #241)."""
    pyproject = (render / "backend" / "pyproject.toml").read_text()
    assert 'packages = ["src/fs"]' in pyproject
    assert 'packages = ["src"]' not in pyproject


@pytest.mark.parametrize(
    "template_name, extra_context",
    [
        ("backend", {"project_name": "be", "use_db": "yes"}),
        ("fullstack", {"project_name": "fs", "use_db": "yes"}),
    ],
)
def test_db_connection_models_path_no_src_prefix(render, template_name):
    """``_MODELS_PATH`` must be ``<package>.db.models`` (NOT ``src.<package>...``).

    Issue #241: with nested ``src/<package>/`` layout the import path is
    ``<package>.db.models`` (no ``src.`` prefix — there is no ``src`` package).
    """
    if template_name == "backend":
        conn = render / "src" / "be" / "db" / "connection.py"
    else:
        conn = render / "backend" / "src" / "fs" / "db" / "connection.py"
    text = conn.read_text()
    assert 'be.db.models"' in text or 'fs.db.models"' in text
    assert "src." not in text, "_MODELS_PATH must not use src. prefix (nested layout)"


def extra_context_value(render, key):
    """Recover the extra_context value for ``key`` from the rendered project.

    The fixtures pass ``project_name`` explicitly; we infer it back from the
    top-level dir name (cookiecutter uses it as the project dir).
    """
    return render.name


# ── project-status oracle compatibility ───────────────────────────────────────


def _run_status_checks(repo_root: Path, fast: bool = True) -> tuple[str, list[ps.CheckResult]]:
    """Run the project-status checks against ``repo_root`` (in-process).

    Builds a ``RepoCtx`` rooted at ``repo_root`` so check-functions get an
    explicit context (no module-global mutation after the #242 refactor).
    """
    ctx = ps.RepoCtx(root=repo_root, config=ps.load_config(repo_root))
    ptype = ps.detect_project_type(ctx)
    groups = ps.run_all_checks(ptype, ctx, fast=fast)
    all_checks = [c for g in groups for c in g.checks]
    report = ps.format_output(ptype, groups)
    return report, all_checks


@pytest.mark.parametrize(
    "template_name, extra_context",
    [("backend", {"project_name": "be", "use_db": "yes", "use_auth": "yes"})],
)
def test_backend_project_status_quality_and_infra_pass(render):
    """Backend template passes the quality + infra checks.

    README is intentionally absent (issue #269 — generated later via
    repo-readme/create-readme on manual call), so the README group yields
    WARN, not FAIL (non-blocking). Coverage of the WARN behavior lives in
    ``test_readme_absent_is_warn``.

    Note: the structure check (``src/api/v1`` flat layout) expects the layout
    from issue #2 — out of scope for this PR. The nested ``src/<pkg>/api/v1``
    layout (per issue #229) is detected as ``unknown`` by the current oracle;
    full compatibility lands after issue #2.
    """
    _report, checks = _run_status_checks(render)
    by_name = {c.name: c for c in checks}
    # quality (ruff/mypy/pytest) must be OK regardless of layout
    assert by_name["ruff"].status == ps.CheckStatus.OK
    assert by_name["mypy"].status == ps.CheckStatus.OK
    assert by_name["pytest"].status == ps.CheckStatus.OK
    # infra: ci.yml + LICENSE + pre-commit
    assert by_name[".github/workflows/ci.yml"].status == ps.CheckStatus.OK
    assert by_name["LICENSE"].status == ps.CheckStatus.OK
    assert by_name["pre-commit"].status == ps.CheckStatus.OK
    # main.py exists (lifespan check may WARN for unknown type — but file is present)
    assert (render / "main.py").exists()
    assert "lifespan" in (render / "main.py").read_text()


@pytest.mark.parametrize(
    "template_name, extra_context",
    [("backend", {"project_name": "be"}), ("cli", {"project_name": "cl"})],
)
def test_pyproject_quality_checks_pass(render):
    """ruff/mypy/pytest presence checks pass in the rendered pyproject."""
    _, checks = _run_status_checks(render)
    by_name = {c.name: c for c in checks}
    assert by_name["ruff"].status == ps.CheckStatus.OK
    assert by_name["mypy"].status == ps.CheckStatus.OK
    assert by_name["pytest"].status == ps.CheckStatus.OK


@pytest.mark.parametrize(
    "template_name, extra_context",
    [("backend", {"project_name": "be"}), ("cli", {"project_name": "cl"})],
)
def test_readme_absent_is_warn(render):
    """README.md is intentionally absent (issue #269) — project-status yields
    WARN, not FAIL (non-blocking, exit 0)."""
    _, checks = _run_status_checks(render)
    by_name = {c.name: c for c in checks}
    assert by_name["README.md"].status == ps.CheckStatus.WARN
    assert "12 delimiter tags" not in by_name


@pytest.mark.parametrize(
    "template_name, extra_context",
    [
        ("backend", {"project_name": "be"}),
        ("cli", {"project_name": "cl"}),
    ],
)
def test_infra_checks_present(render):
    """ci.yml + LICENSE checks pass; pre-commit is present for backend."""
    _, checks = _run_status_checks(render, fast=True)
    by_name = {c.name: c for c in checks}
    assert by_name[".github/workflows/ci.yml"].status == ps.CheckStatus.OK
    assert by_name["LICENSE"].status == ps.CheckStatus.OK


@pytest.mark.parametrize(
    "template_name, extra_context",
    [
        ("fullstack", {"project_name": "fs", "use_db": "yes", "use_auth": "yes"}),
        ("fullstack", {"project_name": "fs", "use_db": "no", "use_auth": "yes"}),
    ],
)
def test_fullstack_passes_project_status_all_groups(render, extra_context):
    """Fullstack template passes the project-status groups (issue #274/#275).

    With ``use_db=yes`` all backend-source checks (Structure, Тонкие роуты,
    Качество кода, Тесты, Pyproject, Coverage, Infra) resolve against
    ``backend/`` and yield OK/WARN. With ``use_db=no`` the db dep is absent
    from ``pyproject.toml``, so ``db/models`` is auto-skipped and the project
    still detects as FULLSTACK; ``Тесты`` may WARN because the use_db=no hook
    strips all db-related test files (test_auth.py included when
    use_auth=yes, since auth_service imports the removed User).

    Issue #275: all checks are non-blocking — no group ever returns FAIL. The
    exit code is always 0 (informational mode); this test asserts that no
    group contains a FAIL check, which is now guaranteed by the contract.

    README is intentionally absent (issue #269 → WARN, non-blocking) and is
    not asserted here. Branch protection is skipped via ``--fast`` (WARN).
    """
    ptype = ps.ProjectType.FULLSTACK
    ctx = ps.RepoCtx(root=render, config=ps.load_config(render))
    detected = ps.detect_project_type(ctx)
    assert detected == ptype
    ctx = ps.RepoCtx(root=render, config=ctx.config, backend_root=ps._backend_root_for(ptype, ctx))
    groups = ps.run_all_checks(ptype, ctx, fast=True)
    by_name = {g.name: g for g in groups}
    # Structure: backend/ + frontend/ present, nested src/<pkg>/api/v1 OK.
    structure = by_name["Структура"]
    assert structure.overall() != ps.CheckStatus.FAIL, (
        f"Структура FAIL: {[c for c in structure.checks if c.status == ps.CheckStatus.FAIL]}"
    )
    # FULLSTACK backend-source checks are rooted at backend/ (issue #274):
    # the expected paths are src/<pkg>/api/v1, schemas, services, ... — and
    # root-level backend/ + frontend/ dirs are present (detection requirement).
    assert (render / "backend").is_dir(), "fullstack requires backend/ dir"
    assert (render / "frontend").is_dir(), "fullstack requires frontend/ dir"
    assert any(
        c.name == "src/fs/api/v1" and c.status == ps.CheckStatus.OK for c in structure.checks
    ), f"src/fs/api/v1 not OK: {structure.checks}"
    # Issue #275: all checks are WARN (non-blocking) — no group can FAIL.
    # Assert every group stays WARN-or-OK (no FAIL check anywhere).
    for group_name, g in by_name.items():
        failed = [c for c in g.checks if c.status == ps.CheckStatus.FAIL]
        assert not failed, f"{group_name} has FAIL checks (issue #275 violation): " + ", ".join(
            f"{c.name}={c.status.value}" for c in failed
        )


@pytest.mark.parametrize("template_name, extra_context", [("cli", {"project_name": "cl"})])
def test_cli_passes_package_check(render):
    """CLI template renders a src/<package>/__init__.py package."""
    _, checks = _run_status_checks(render)
    by_name = {c.name: c for c in checks}
    assert by_name["src/<package>/"].status == ps.CheckStatus.OK


# ── fixes from slaid098/templates ────────────────────────────────────────────


@pytest.mark.parametrize(
    "template_name, extra_context",
    [("backend", {"project_name": "be", "use_db": "yes", "use_auth": "yes"})],
)
def test_no_hardcoded_production_ips(render):
    """IP whitelist defaults to 127.0.0.1 + ::1 (no hardcoded prod IPs)."""
    deps = (render / "src/be/api/v1/dependencies.py").read_text()
    assert '"127.0.0.1"' in deps
    assert '"::1"' in deps
    # env.example also uses the safe default, never a prod IP
    env = (render / "env.example").read_text()
    assert "127.0.0.1" in env
    assert "::1" in env


@pytest.mark.parametrize("template_name, extra_context", [("backend", {"project_name": "be"})])
def test_no_metadata_duplication_in_init(render):
    """metadata lives ONLY in metadata.py — __init__.py of utils does not redeclare."""
    init = (render / "src/be/utils/__init__.py").read_text()
    meta = (render / "src/be/utils/metadata.py").read_text()
    # metadata.py declares the dataclass + load_metadata; __init__ only re-exports
    assert "class ProjectMetadata" in meta
    assert "def load_metadata" in meta
    assert "class ProjectMetadata" not in init, "init must not redeclare the dataclass"


@pytest.mark.parametrize(
    "template_name, extra_context",
    [("backend", {"project_name": "be", "use_db": "yes"})],
)
def test_user_service_get_users_returns_list(render):
    """get_users returns a list (NOT a tuple — bug fixed)."""
    source = (render / "src/be/services/user_service.py").read_text()
    # The signature declares list[User] and the body wraps with list(...)
    assert "list[User]" in source
    assert "return list(" in source
    # no `return (` returning a bare tuple of query results
    assert "return (" not in source
    assert "return tuple" not in source.lower().replace("not a tuple", "")


@pytest.mark.parametrize(
    "template_name, extra_context",
    [("backend", {"project_name": "be", "use_db": "yes"})],
)
def test_path_relative_not_cwd(render):
    """main.py uses Path(__file__) for StaticFiles (not CWD-relative)."""
    main = (render / "main.py").read_text()
    assert "Path(__file__)" in main
    # no os.getcwd() reliance
    assert "os.getcwd()" not in main


@pytest.mark.parametrize(
    "template_name, extra_context",
    [("backend", {"project_name": "be", "use_db": "yes"})],
)
def test_migrations_dir_present(render):
    """migrations/ directory exists for Tortoise built-in migrator (NOT Aerich)."""
    assert (render / "migrations").is_dir()
    # the README in migrations mentions Tortoise; no `aerich` CLI commands
    readme = (render / "migrations/README.md").read_text()
    assert "Tortoise" in readme
    assert "aerich" not in readme.lower()


@pytest.mark.parametrize(
    "template_name, extra_context",
    [("backend", {"project_name": "be", "use_auth": "yes"})],
)
def test_jwt_auth_template_present(render):
    """use_auth=yes renders the full JWT auth stack."""
    assert (render / "src/be/services/auth_service.py").exists()
    assert (render / "src/be/api/v1/routes/auth.py").exists()
    # auth_service uses passlib[bcrypt] + pyjwt
    auth_src = (render / "src/be/services/auth_service.py").read_text()
    assert "passlib" in auth_src
    assert "CryptContext" in auth_src
    assert "bcrypt" in auth_src
    assert "jwt" in auth_src.lower() or "import jwt" in auth_src
    # auth routes expose /login + /register
    routes = (render / "src/be/api/v1/routes/auth.py").read_text()
    assert "/login" in routes
    assert "/register" in routes
    # schemas include UserCreate/UserLogin/Token
    schemas = (render / "src/be/schemas/user.py").read_text()
    assert "class UserCreate" in schemas
    assert "class UserLogin" in schemas
    assert "class Token" in schemas
    # pyproject pulls passlib + pyjwt
    pyproject = (render / "pyproject.toml").read_text()
    assert "passlib[bcrypt]" in pyproject
    assert "pyjwt" in pyproject


@pytest.mark.parametrize(
    "template_name, extra_context",
    [("backend", {"project_name": "be", "use_auth": "no"})],
)
def test_jwt_auth_template_absent_when_disabled(render):
    """use_auth=no strips the entire JWT auth stack + deps."""
    assert not (render / "src/be/services/auth_service.py").exists()
    assert not (render / "src/be/api/v1/routes/auth.py").exists()
    pyproject = (render / "pyproject.toml").read_text()
    assert "passlib" not in pyproject
    assert "pyjwt" not in pyproject


# ── hooks: post_gen_project runs cleanly ──────────────────────────────────────


@pytest.mark.parametrize(
    "template_name, extra_context",
    [
        ("backend", {"project_name": "be", "use_db": "no", "use_auth": "no"}),
        ("fullstack", {"project_name": "fs", "use_db": "no", "use_auth": "no"}),
    ],
)
def test_post_gen_hook_strips_conditionals(render, template_name):
    """The post-gen hook strips db + auth files when both flags are 'no'.

    Also strips db-dependent files (broken-conditional fix from PR#235 review).
    """
    # same assertions as the structure tests, but explicitly verifies the
    # hook ran (cookiecutter would have failed otherwise)
    if template_name == "backend":
        assert not (render / "src/be/db").exists()
        assert not (render / "migrations").exists()
        assert not (render / "src/be/api/v1/routes/users.py").exists()
        assert not (render / "src/be/services/user_service.py").exists()
        assert not (render / "src/be/api/v1/dependencies.py").exists()
        assert not (render / "src/be/schemas/user.py").exists()
    else:
        assert not (render / "backend/src/fs/db").exists()
        assert not (render / "backend/migrations").exists()
        assert not (render / "backend/src/fs/api/v1/routes/users.py").exists()
        assert not (render / "backend/src/fs/services/user_service.py").exists()
        assert not (render / "backend/src/fs/api/v1/dependencies.py").exists()
        assert not (render / "backend/src/fs/schemas/user.py").exists()


# ── thin routes: ≤ 50 lines per handler ───────────────────────────────────────


@pytest.mark.parametrize(
    "template_name, extra_context",
    [
        ("backend", {"project_name": "be", "use_db": "yes", "use_auth": "yes"}),
        ("fullstack", {"project_name": "fs", "use_db": "yes", "use_auth": "yes"}),
    ],
)
def test_routes_are_thin(render, template_name):
    """Each route handler file stays under 50 lines (thin routes contract)."""
    if template_name == "backend":
        routes_dir = render / "src/be/api/v1/routes"
    else:
        routes_dir = render / "backend/src/fs/api/v1/routes"
    for route_file in routes_dir.glob("*.py"):
        if route_file.name == "__init__.py":
            continue
        content = route_file.read_text()
        line_count = len(content.splitlines())
        assert line_count <= 50, f"{route_file.name}: {line_count} lines (limit 50)"


# ── smoke: no dangling imports of stripped modules ────────────────────────────


# Modules that are removed by the post-gen hook when their dependency flag is
# "no". Any surviving ``from <pkg>.<module>`` import in the generated tree is
# an ImportError waiting to happen (the broken-conditional root cause).
_STRIPPED_MODULES = {
    ("db", "no"): ["db.models.user", "db.connection", "services.user_service"],
    ("auth", "no"): ["services.auth_service", "api.v1.routes.auth"],
}


@pytest.mark.parametrize(
    "template_name, extra_context",
    [
        ("backend", {"project_name": "be", "use_db": "no", "use_auth": "no"}),
        ("backend", {"project_name": "be", "use_db": "no", "use_auth": "yes"}),
        ("backend", {"project_name": "be", "use_db": "yes", "use_auth": "no"}),
        ("backend", {"project_name": "be", "use_db": "yes", "use_auth": "yes"}),
        ("fullstack", {"project_name": "fs", "use_db": "no", "use_auth": "no"}),
        ("fullstack", {"project_name": "fs", "use_db": "no", "use_auth": "yes"}),
        ("fullstack", {"project_name": "fs", "use_db": "yes", "use_auth": "no"}),
        ("fullstack", {"project_name": "fs", "use_db": "yes", "use_auth": "yes"}),
    ],
)
def test_no_dangling_imports_of_stripped_modules(render, template_name, extra_context):
    """No generated ``.py`` imports a module that the hook removed.

    Regression guard for the broken-conditional findings in PR#235 review:
    when ``use_db=no`` the hook deletes ``db/`` and the db-dependent files, so
    no surviving file may reference ``db.models.user`` / ``user_service`` /
    ``schemas.user`` / ``dependencies`` / ``routes.users``. Likewise auth.
    """
    use_db = extra_context.get("use_db", "yes")
    use_auth = extra_context.get("use_auth", "yes")
    pkg = extra_context["project_name"]

    forbidden = []
    if use_db == "no":
        forbidden += [
            f"{pkg}.db.models.user",
            f"{pkg}.db.connection",
            f"{pkg}.services.user_service",
            f"{pkg}.schemas.user",
            f"{pkg}.api.v1.dependencies",
            f"{pkg}.api.v1.routes.users",
        ]
    if use_auth == "no" or use_db == "no":
        # auth_service imports User -> requires db; stripped when either is "no"
        forbidden += [
            f"{pkg}.services.auth_service",
            f"{pkg}.api.v1.routes.auth",
        ]

    offenders: list[str] = []
    for py in render.rglob("*.py"):
        text = py.read_text()
        for mod in forbidden:
            if f"from {mod}" in text or f"import {mod}" in text:
                offenders.append(f"{py.relative_to(render)}: {mod}")
    assert not offenders, "dangling imports of stripped modules:\n" + "\n".join(offenders)


@pytest.mark.parametrize(
    "template_name, extra_context",
    [
        ("backend", {"project_name": "be", "use_db": "no", "use_auth": "no"}),
        ("backend", {"project_name": "be", "use_db": "no", "use_auth": "yes"}),
        ("backend", {"project_name": "be", "use_db": "yes", "use_auth": "no"}),
        ("backend", {"project_name": "be", "use_db": "yes", "use_auth": "yes"}),
    ],
)
def test_main_imports_setup_logging_unconditionally(render):
    """``main.py`` must import ``setup_logging`` outside the ``use_db`` block.

    Regression guard for critical #1 of PR#235 review: the lifespan ``else``
    branch calls ``setup_logging()`` even when ``use_db=no``, so the import
    must not be gated behind ``{% if cookiecutter.use_db == "yes" %}``.
    """
    main = (render / "main.py").read_text()
    assert "from " in main and "setup_logging" in main
    # the import line itself must NOT sit inside a use_db conditional —
    # verify by checking the import is present and there is no stray
    # ``setup_logging()`` call without a preceding import in the same file.
    import_lines = [ln for ln in main.splitlines() if "import" in ln and "setup_logging" in ln]
    assert import_lines, "setup_logging not imported in main.py"


# ── issue #262: project_name must be a valid Python identifier ──────────────

# Regex mirrored in every ``pre_gen_project.py`` hook (kept here as a single
# source of truth for the test, validated against the actual hook files).
_VALID_PROJECT_NAME = re.compile(r"^[a-z][a-z0-9_]*$")


@pytest.mark.parametrize(
    "template_name, bad_name",
    [
        ("backend", "my-project"),  # hyphen
        ("backend", "my.project"),  # dot
        ("backend", "my project"),  # space
        ("backend", "My_Project"),  # uppercase
        ("backend", "123abc"),  # leading digit
        ("fullstack", "my-fullstack"),
        ("fullstack", "My_Fullstack"),
        ("cli", "my-cli"),
        ("cli", "123cli"),
    ],
)
def test_pre_gen_hook_rejects_invalid_project_name(tmp_path, template_name, bad_name):
    """pre_gen_project.py exits non-zero for invalid project_name.

    Regression guard for issue #262: hyphens/dots/spaces/uppercase/leading
    digits in ``project_name`` would render as ``from my-project.X import Y``
    which is a ``SyntaxError`` in the generated project. The hook must abort
    cookiecutter with ``FailedHookException`` BEFORE any file is rendered.
    """
    with pytest.raises(FailedHookException):
        cookiecutter(
            template=str(TEMPLATES_DIR / template_name),
            no_input=True,
            output_dir=str(tmp_path),
            extra_context={"project_name": bad_name},
        )
    # nothing rendered
    assert not (tmp_path / bad_name).exists()


@pytest.mark.parametrize(
    "template_name, good_name",
    [
        ("backend", "my_project"),
        ("fullstack", "my_fullstack"),
        ("cli", "my_cli"),
        ("backend", "be"),
        ("cli", "cl"),
        ("fullstack", "fs"),
    ],
)
def test_pre_gen_hook_accepts_valid_project_name(tmp_path, template_name, good_name):
    """Valid identifiers pass the hook and render a project."""
    out = cookiecutter(
        template=str(TEMPLATES_DIR / template_name),
        no_input=True,
        output_dir=str(tmp_path),
        extra_context={"project_name": good_name},
    )
    assert Path(out).is_dir()


@pytest.mark.parametrize("template_name", ["backend", "cli", "fullstack"])
def test_cookiecutter_json_default_is_valid_identifier(template_name):
    """The default ``project_name`` in cookiecutter.json passes the hook regex.

    Issue #262: defaults must NOT contain hyphens (``my-project``) — they
    are used as-is in paths/imports and would break the no-input flow.
    """
    cfg = json.loads((TEMPLATES_DIR / template_name / "cookiecutter.json").read_text())
    default = cfg["project_name"]
    assert _VALID_PROJECT_NAME.fullmatch(default), (
        f"default project_name {default!r} in {template_name}/cookiecutter.json "
        f"is not a valid Python identifier (^[a-z][a-z0-9_]*$)"
    )


# ── issue #274: cookiecutter use_db=no removes db dep (auto-detect) ─────────


@pytest.mark.parametrize(
    "template_name, extra_context",
    [
        ("backend", {"project_name": "demo_be", "use_db": "yes", "use_auth": "yes"}),
        ("cli", {"project_name": "demo_cli"}),
        ("fullstack", {"project_name": "demo_fs", "use_db": "yes", "use_auth": "yes"}),
    ],
)
def test_generated_imports_are_parseable(render, template_name, extra_context):
    """Every generated ``.py`` parses with ``ast.parse`` (no SyntaxError).

    Issue #262: hyphens in ``project_name`` produced ``from my-project.X
    import Y`` which is a ``SyntaxError``. With the pre_gen hook the name
    is already a valid identifier, so all generated imports must parse.
    """
    pkg = extra_context["project_name"]
    offenders: list[str] = []
    for py in render.rglob("*.py"):
        text = py.read_text()
        try:
            ast.parse(text)
        except SyntaxError as exc:
            offenders.append(f"{py.relative_to(render)}: {exc}")
    assert not offenders, f"SyntaxError in generated files (pkg={pkg}):\n" + "\n".join(offenders)


@pytest.mark.parametrize(
    "template_name, extra_context",
    [
        ("backend", {"project_name": "demo_be"}),
        ("cli", {"project_name": "demo_cli"}),
        ("fullstack", {"project_name": "demo_fs"}),
    ],
)
def test_resolve_package_name_matches_directory(render, template_name, extra_context):
    """``_resolve_package_name`` from project-status matches the package dir.

    Issue #262: with a valid identifier ``project_name``, the directory name
    equals the package name, and ``_resolve_package_name`` (which normalizes
    ``[project].name`` via PEP 503) returns the same value. This is the
    contract that ``project-status`` relies on to detect BACKEND/FULLSTACK/
    CLI without false FAIL/WARN.
    """
    pkg = extra_context["project_name"]
    ctx = ps.RepoCtx(root=render, config=ps.load_config(render))
    # fullstack: pyproject.toml lives under ``backend/`` (see _api_dirs_for
    # in project-status.py), so resolve against a backend-rooted context.
    if template_name == "fullstack":
        ctx = ps.RepoCtx(root=render / "backend", config=ps.load_config(render / "backend"))
    resolved = ps._resolve_package_name(ctx)
    assert resolved is not None, "_resolve_package_name returned None"
    assert resolved == pkg, (
        f"_resolve_package_name()={resolved!r} != directory name {pkg!r}; "
        f"project-status would look for src/{resolved}/ but the real dir is src/{pkg}/"
    )
    # fullstack: the package dir lives under backend/src/
    if template_name == "fullstack":
        assert (render / "backend" / "src" / pkg).is_dir()
    else:
        assert (render / "src" / pkg).is_dir()
