# ═══════════════════════════════════════════════════════════════════════════════
# coding.sgit.ai — Test__House_Style
# The seven house-style rules no linter can express, as one structural guard in the
# style of the four that already exist in tests/ci/. Drop it in, set TREE, run pytest.
# Published under CC BY 4.0 — https://coding.sgit.ai/enforce/
# ═══════════════════════════════════════════════════════════════════════════════

import ast
import os
import re
from pathlib    import Path
from unittest   import TestCase

SKIP_DIRS         = {'tests', '.venv', 'venv', 'build', 'dist', '.git', 'node_modules'}
BANNER_RULE       = re.compile(r'^# ═{40,}$')                                    # the opening and closing rules
RAW_PRIMITIVES    = {'str', 'int', 'float', 'bool', 'list', 'dict', 'set', 'tuple'}
ALLOW_UNALIGNED   = 1                                                            # a lone import cannot be misaligned
BANNER_MAX_LINES  = 12                                                           # Section__Shutdown's banner is 6 lines


def repo_root():                                                                 # the first parent carrying a pyproject
    here = Path(__file__).resolve()
    for parent in here.parents:
        if (parent / 'pyproject.toml').exists():
            return parent
    return here.parent


# HOUSE_STYLE__TREE overrides it, because a guard that checks the wrong directory
# passes vacuously — which is exactly the failure this whole page is about.
TREE = Path(os.environ.get('HOUSE_STYLE__TREE') or repo_root()).resolve()


def python_files():                                                              # every .py under the tree, tests excluded
    for path in sorted(TREE.rglob('*.py')):
        if SKIP_DIRS & set(path.parts) or path.name.startswith('test_'):
            continue
        yield path


def class_defs(tree):
    return [node for node in tree.body if isinstance(node, ast.ClassDef)]


class Test__House_Style(TestCase):

    @classmethod
    def setUpClass(cls):
        cls.files   = list(python_files())
        cls.parsed  = {}
        for path in cls.files:                                                   # parse once, reuse in every test
            try:
                cls.parsed[path] = ast.parse(path.read_text())
            except SyntaxError as error:
                raise AssertionError(f'{path}: does not parse: {error}')

    # ── rule 7 — a ═══ banner on every file, naming the file and its purpose ──
    # The rule as written says "three content lines". Every verbatim example in the
    # estate has two or more: one "<product> — <ClassName>" line, then one or more
    # purpose lines — Section__Shutdown has four. The count is checked as a range and
    # the identity line is checked exactly, because the identity line is the part a
    # reader actually uses. See https://coding.sgit.ai/enforce/#banner-discrepancy
    def test_banner_present_and_well_formed(self):
        offenders = []
        for path in self.files:
            if path.name == '__init__.py':
                continue
            lines = path.read_text().splitlines()[:BANNER_MAX_LINES]
            if not lines or not BANNER_RULE.match(lines[0]):
                offenders.append(f'{path}: no ═══ banner on line 1')
                continue
            closing = next((i for i, line in enumerate(lines[1:], 1)
                            if BANNER_RULE.match(line)), None)
            if closing is None:
                offenders.append(f'{path}: banner is not closed within '
                                 f'{BANNER_MAX_LINES} lines')
                continue
            content = lines[1:closing]
            if len(content) < 2:
                offenders.append(f'{path}: banner has {len(content)} content line(s); '
                                 f'expected an identity line and at least one purpose line')
            elif path.stem not in content[0]:
                offenders.append(f'{path}: banner identity line does not name '
                                 f'{path.stem}: {content[0]!r}')
        self.assertEqual([], offenders)

    # ── rule 8 — inline comments only, no docstrings, ever ────────────────────
    def test_no_docstrings(self):
        offenders = []
        for path, tree in self.parsed.items():
            nodes = [tree] + [n for n in ast.walk(tree)
                              if isinstance(n, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef))]
            for node in nodes:
                if ast.get_docstring(node) is not None:
                    where = getattr(node, 'name', '<module>')
                    offenders.append(f'{path}: {where} has a docstring; use a trailing comment')
        self.assertEqual([], offenders)

    # ── rule 9 — no underscore prefix for private methods ─────────────────────
    def test_no_underscore_prefixed_methods(self):
        offenders = []
        for path, tree in self.parsed.items():
            for node in ast.walk(tree):
                if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                    continue
                if node.name.startswith('_') and not node.name.startswith('__'):
                    offenders.append(f'{path}:{node.lineno}: def {node.name}')
        self.assertEqual([], offenders)                                          # see coding.sgit.ai/open-questions/#q2

    # ── rule 21 — one class per file, filename identical to the class name ────
    def test_one_class_per_file_named_for_the_file(self):
        offenders = []
        for path, tree in self.parsed.items():
            if path.name == '__init__.py' or path.name.endswith('_registry.py'):  # the documented carve-out
                continue
            classes = class_defs(tree)
            if len(classes) > 1:
                offenders.append(f'{path}: {len(classes)} classes: ' +
                                 ', '.join(c.name for c in classes))
            elif classes and classes[0].name != path.stem:
                offenders.append(f'{path}: defines {classes[0].name}, not {path.stem}')
        self.assertEqual([], offenders)

    # ── rule 22 — __init__.py stays empty ─────────────────────────────────────
    def test_init_files_are_empty(self):
        offenders = [str(p) for p in TREE.rglob('__init__.py')
                     if not SKIP_DIRS & set(p.parts) and p.read_text().strip()]
        self.assertEqual([], offenders)

    # ── rule 2 — zero raw primitives as class attributes ──────────────────────
    def test_no_raw_primitive_attributes(self):
        offenders = []
        for path, tree in self.parsed.items():
            for klass in class_defs(tree):
                for node in klass.body:
                    if not isinstance(node, ast.AnnAssign):
                        continue
                    name = getattr(node.annotation, 'id', None)                  # bare name annotations only
                    if name in RAW_PRIMITIVES:
                        offenders.append(f'{path}:{node.lineno}: '
                                         f'{klass.name}.{node.target.id} : {name} — '
                                         f'use a constrained primitive')
        self.assertEqual([], offenders)

    # ── the undocumented one — import keywords aligned to a column ────────────
    def test_import_keywords_are_aligned(self):
        offenders = []
        for path in self.files:
            columns = set()
            for line in path.read_text().splitlines():
                if line.startswith('from ') and ' import ' in line:
                    columns.add(line.index(' import '))
            if len(columns) > ALLOW_UNALIGNED:
                offenders.append(f'{path}: imports at {len(columns)} different columns '
                                 f'{sorted(columns)}')
        self.assertEqual([], offenders)                                          # 39% of the estate today
