# ═══════════════════════════════════════════════════════════════════════════════
# coding.sgit.ai — Check__Markup
# The two HTML rules that catch defects rather than formatting: every interactive
# control has an accessible name, and behaviour never lives in an on* attribute.
# Plus the fragment rule — a component's markup is a fragment, not a document.
# Published under CC BY 4.0 — https://coding.sgit.ai/enforce/
# ═══════════════════════════════════════════════════════════════════════════════

import sys
from html.parser import HTMLParser
from pathlib     import Path

TREE           = Path(sys.argv[1] if len(sys.argv) > 1 else '.').resolve()
INTERACTIVE    = {'button', 'a', 'input', 'select', 'textarea', 'summary'}
NAMING_ATTRS   = ('aria-label', 'aria-labelledby', 'title', 'alt', 'placeholder')
DOCUMENT_TAGS  = {'html', 'head', 'body', 'template'}                            # a fragment has none of these
NO_NAME_NEEDED = {'hidden', 'aria-hidden'}
INDENT         = 2


class Fragment__Parser(HTMLParser):

    def __init__(self, path):
        super().__init__(convert_charrefs=True)
        self.path      = path
        self.problems  = []
        self.open      = []                                                      # stack of [tag, attrs, text, line]

    def handle_starttag(self, tag, attrs):
        attributes = dict(attrs)
        line       = self.getpos()[0]

        if tag in DOCUMENT_TAGS:                                                 # component markup is a fragment
            self.problems.append(f'{self.path}:{line}: <{tag}> — component markup is a '
                                 f'fragment, not a document')

        for name in attributes:                                                  # behaviour is data-*, never on*
            if name.startswith('on'):
                self.problems.append(f'{self.path}:{line}: <{tag} {name}=…> — behaviour belongs '
                                     f'in JS keyed on a data-* attribute, not in markup')

        if tag in INTERACTIVE or attributes.get('role') in ('button', 'link', 'tab'):
            self.open.append([tag, attributes, '', line])

    def handle_data(self, data):
        if self.open and data.strip():
            self.open[-1][2] += data.strip()

    def handle_endtag(self, tag):
        while self.open and self.open[-1][0] != tag:                             # tolerate unclosed markup
            self.check(self.open.pop())
        if self.open:
            self.check(self.open.pop())

    def close(self):
        super().close()
        while self.open:                                                         # an excerpt may end mid-element
            self.check(self.open.pop())

    def check(self, element):
        tag, attributes, text, line = element
        if any(a in attributes for a in NO_NAME_NEEDED):
            return
        if text or any(attributes.get(a) for a in NAMING_ATTRS):
            return
        self.problems.append(f'{self.path}:{line}: <{tag}> has no accessible name — '
                             f'add text content or aria-label')


def check_indent(path, text):
    problems = []
    for number, line in enumerate(text.splitlines(), 1):
        stripped = line.lstrip(' ')
        if not stripped or line.startswith('\t'):
            if line.startswith('\t'):
                problems.append(f'{path}:{number}: tab indent — this markup is {INDENT}-space')
            continue
        depth = len(line) - len(stripped)
        if depth % INDENT:
            problems.append(f'{path}:{number}: indented {depth}, not a multiple of {INDENT}')
    return problems


def check():
    problems = []
    for path in sorted(TREE.rglob('*.html')):
        text   = path.read_text()
        parser = Fragment__Parser(path)
        parser.feed(text)
        parser.close()
        problems += parser.problems
        problems += check_indent(path, text)
    return problems


if __name__ == '__main__':
    found = check()
    for problem in found:
        print(f'  ✗ {problem}', file=sys.stderr)
    print(f'check_markup: {len(found)} problem(s)', file=sys.stderr if found else sys.stdout)
    sys.exit(1 if found else 0)
