#!/usr/bin/env python3
"""Check the customizing catalog (manga/server/lib/css-catalog.json) against the code (standard library only).

    python manga/dev/css_catalog_check.py              compare with the latest v* tag as well
    python manga/dev/css_catalog_check.py --base REF   compare with REF (a tag, branch or commit)
    python manga/dev/css_catalog_check.py --release    also refuse a "next" version (run by build_release.py)

Fails when
- a class in viewer/log.css or a --log-* variable is missing from the catalog (public, state or internal),
- a catalog name no longer exists in the stylesheet or the code (generated names are listed in "dynamic"),
- a public name of the base version disappeared, stopped being public or was renamed without a history entry
  (removed / renamed / deprecated) newer than the base,
- a name added after the initial list has no matching "added" entry, or the history is out of order.
The admin only reads the catalog; nothing here runs on the server or on a phone.
"""
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path

ROOT = next(p for p in Path(__file__).resolve().parents if (p / 'manga/server').is_dir())
SERVER = ROOT / 'manga/server'
CATALOG = SERVER / 'lib/css-catalog.json'
CATALOG_REL = 'manga/server/lib/css-catalog.json'
STATE = re.compile(r'\A(?:is|can|has)-|\A(?:today|selected)\Z')
if hasattr(sys.stdout, 'reconfigure'):
    sys.stdout.reconfigure(encoding='utf-8')


def css_names(text):
    text = re.sub(r'/\*.*?\*/', '', text, flags=re.S)
    # Class names in selectors only: drop declaration blocks' values (numbers like .5s are not classes).
    selectors = re.sub(r'\{[^{}]*\}', '{}', text)
    classes = set(re.findall(r'\.(-?[_a-zA-Z][\w-]*)', selectors))
    variables = set(re.findall(r'(--log-[\w-]+)\s*:', text))
    return classes, variables


def code_text():
    files = [*SERVER.glob('*.php'), *(SERVER / 'lib').glob('*.php'), *(SERVER / 'viewer').glob('log*.js'), SERVER / 'viewer/NagiManga.js']
    return '\n'.join(f.read_text(encoding='utf-8') for f in files)


def base_ref(arg):
    if arg:
        return arg
    try:
        return subprocess.run(['git', 'describe', '--tags', '--abbrev=0', '--match', 'v*'], cwd=ROOT, capture_output=True, text=True, check=True).stdout.strip()
    except (OSError, subprocess.CalledProcessError):
        return ''


def base_catalog(ref):
    if not ref:
        return None
    r = subprocess.run(['git', 'show', f'{ref}:{CATALOG_REL}'], cwd=ROOT, capture_output=True)
    return json.loads(r.stdout.decode('utf-8')) if r.returncode == 0 else None


def names_of(entries):
    out = []
    for e in entries:
        out += [n for n in re.findall(r'\.(-?[_a-zA-Z][\w-]*)', e['name'])] if e['name'].startswith(('.', 'html', 'body')) else []
    return out


def history_names(entry, kind):
    out = []
    for c in entry.get(kind, []):
        if isinstance(c, dict):
            out += [c['from'], c['to']] if 'from' in c else [c.get('name', '')]
        else:
            out.append(c)
    return out


DOCS = {'ja': ROOT / 'manga/README-LOG.md', 'en': ROOT / 'manga/README-LOG.en.md'}
DOC_TEXT = {
    'ja': {'head': '| 名前 | 用途 | 対象の画面 | 記述例 | 追加 |', 'initial': '初期一覧', 'next': '次のバージョン', 'selectors': '公開セレクター', 'variables': 'CSS変数',
           'pages': {'all': 'すべてのページ', 'list': '投稿一覧', 'post': '記事のページ', 'page': '固定ページ', 'sidebar': 'サイドバー', 'tiles': 'タイル表示の一覧', 'footer': 'サイト下部'}},
    'en': {'head': '| Name | Use | Screens | Example | Added |', 'initial': 'Initial list', 'next': 'Next version', 'selectors': 'Public selectors', 'variables': 'CSS variables',
           'pages': {'all': 'All pages', 'list': 'Post lists', 'post': 'Post pages', 'page': 'Fixed pages', 'sidebar': 'Sidebar', 'tiles': 'Tile lists', 'footer': 'Footer'}},
}
BEGIN, END = '<!-- css-catalog:begin (generated by manga/dev/css_catalog_check.py --write-docs) -->', '<!-- css-catalog:end -->'


def docs_block(cat, lang):
    """The README copy of the catalog: public selectors and CSS variables."""
    t = DOC_TEXT[lang]
    cell = lambda s: str(s).replace('|', '\\|')
    out = [BEGIN]
    for key in ('selectors', 'variables'):
        out += ['', f'**{t[key]}**', '', t['head'], '| --- | --- | --- | --- | --- |']
        for e in cat[key]:
            since = e.get('since', 'initial')
            label = t.get(since) if since in ('initial', 'next') else 'v' + since
            pages = ('・' if lang == 'ja' else ', ').join(t['pages'].get(p, p) for p in e['pages'])
            out.append(f"| `{cell(e['name'])}` | {cell(e['purpose'][lang])} | {pages} | `{cell(e['example'])}` | {label} |")
    return '\n'.join(out + ['', END])


def docs_problems(cat, write):
    problems = []
    for lang, path in DOCS.items():
        text = path.read_text(encoding='utf-8')
        start, end = text.find(BEGIN), text.find(END)
        if start < 0 or end < 0:
            problems.append(f'{path.name} に一覧の目印（{BEGIN[:24]}…）がありません')
            continue
        fresh = text[:start] + docs_block(cat, lang) + text[end + len(END):]
        if fresh != text:
            if write:
                path.write_text(fresh, encoding='utf-8', newline='\n')
                print(f'{path.name} の一覧を書き直しました')
            else:
                problems.append(f'{path.name} の一覧が css-catalog.json と違います。--write-docs で書き直し、サイトを作り直してください')
    return problems


def main():
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument('--write-docs', action='store_true', help='rewrite the list in README-LOG.md / README-LOG.en.md')
    parser.add_argument('--base')
    parser.add_argument('--release', action='store_true')
    # Tests: another catalog, and a base catalog read from a file instead of git.
    parser.add_argument('--catalog', type=Path, default=CATALOG)
    parser.add_argument('--base-file', type=Path)
    args = parser.parse_args()
    problems = []
    cat = json.loads(args.catalog.read_text(encoding='utf-8'))
    classes, variables = css_names((SERVER / 'viewer/log.css').read_text(encoding='utf-8'))
    code = code_text()
    public = cat['selectors']
    public_classes = set(names_of(public))
    listed = public_classes | set(cat['state']) | set(cat['internal'])
    dynamic = set(cat.get('dynamic', []))

    for name in sorted(classes - listed):
        problems.append(f'log.css の .{name} が一覧にありません。css-catalog.json の selectors（公開）・state（状態用）・internal（内部用）のどれかに入れてください')
    for name in sorted((set(cat['state']) | set(cat['internal'])) - classes):
        problems.append(f'.{name} は log.css にもうありません。一覧から外してください（公開していた名前なら history に removed を書きます）')
    for name in sorted(public_classes - classes):
        problems.append(f'公開セレクターの .{name} が log.css にありません')
    for name in sorted(public_classes | set(cat['state'])):
        if name not in dynamic and not re.search(r'(?<![\w-])' + re.escape(name) + r'(?![\w-])', code):
            problems.append(f'.{name} を付けているコードが見つかりません。名前を組み立てて付けている場合は dynamic に登録してください')
    for name in sorted(dynamic - listed):
        problems.append(f'dynamic の {name} が一覧にありません')
    listed_vars = {e['name'] for e in cat['variables']}
    for name in sorted(variables - listed_vars):
        problems.append(f'log.css の CSS変数 {name} が一覧（variables）にありません')
    for name in sorted(listed_vars - variables):
        problems.append(f'CSS変数 {name} は log.css にもうありません')
    # A public selector may combine a state class (.log-post.is-pinned); it may not use an internal one.
    for name in sorted(public_classes & set(cat['internal'])):
        problems.append(f'.{name} が公開と内部用の両方にあります')
    for name in sorted(set(cat['state']) & set(cat['internal'])):
        problems.append(f'.{name} が状態用と内部用の両方にあります')

    # History: versions in order, every later addition recorded.
    versions = [h['version'] for h in cat['history']]
    if not versions or versions[0] != 'initial':
        problems.append('history の最初は version "initial"（初期一覧）にします')
    if 'next' in versions and versions[-1] != 'next':
        problems.append('history の "next" は最後に置きます')
    if args.release and 'next' in versions:
        problems.append('リリースの前に、history と since の "next" を出すバージョン番号に書き換えてください')
    numbered = [v for v in versions if v not in ('initial', 'next')]
    if numbered != sorted(numbered, key=lambda v: tuple(int(x) for x in v.split('.'))):
        problems.append('history のバージョンが古い順に並んでいません')
    added = {}
    for h in cat['history']:
        for n in history_names(h, 'added'):
            added[n] = h['version']
    for e in public + cat['variables']:
        since = e.get('since', 'initial')
        if since not in versions:
            problems.append(f'{e["name"]} の since "{since}" が history にありません')
        elif since != 'initial' and added.get(e['name']) != since:
            problems.append(f'{e["name"]} は {since} の追加ですが、history の added にありません')
        for key in ('purpose', 'example', 'pages'):
            if not e.get(key):
                problems.append(f'{e["name"]} に {key} がありません')
        if isinstance(e.get('purpose'), dict) and not (e['purpose'].get('ja') and e['purpose'].get('en')):
            problems.append(f'{e["name"]} の用途に日本語と英語の両方を書いてください')

    # The README copy follows the catalog (not for the test catalogs given with --catalog).
    if args.catalog == CATALOG:
        problems += docs_problems(cat, args.write_docs)

    # Compared with the base version: public names may only disappear with a history entry.
    ref = str(args.base_file) if args.base_file else base_ref(args.base)
    base = json.loads(args.base_file.read_text(encoding='utf-8')) if args.base_file else base_catalog(ref)
    if base is not None:
        old_public = {e['name'] for e in base['selectors'] + base['variables']}
        now_public = {e['name'] for e in public + cat['variables']}
        old_versions = {h['version'] for h in base['history'] if h['version'] != 'next'}
        recorded = set()
        for h in cat['history']:
            if h['version'] in old_versions:
                continue
            for kind in ('removed', 'renamed', 'deprecated'):
                recorded.update(history_names(h, kind))
        for name in sorted(old_public - now_public):
            if name not in recorded:
                problems.append(f'公開セレクター {name} が {ref} からなくなっています。history に removed か renamed を書いてください')
        old_by = {e['name']: e for e in base['selectors'] + base['variables']}
        changed = set()
        for h in cat['history']:
            if h['version'] not in old_versions:
                changed.update(history_names(h, 'changed'))
        for e in public + cat['variables']:
            o = old_by.get(e['name'])
            if o and (o.get('pages') != e.get('pages')) and e['name'] not in changed:
                problems.append(f'{e["name"]} の対象の画面が {ref} から変わっています。history に changed を書いてください')
        print(f'比較元: {ref}')
    else:
        print(f'比較元: {ref or "（タグなし）"} には一覧がないため、追加・削除の比較はしません')

    for p in problems:
        print('[FAIL]', p)
    print(f'公開 {len(public)}・CSS変数 {len(cat["variables"])}・状態用 {len(cat["state"])}・内部用 {len(cat["internal"])}・問題 {len(problems)}')
    raise SystemExit(1 if problems else 0)


if __name__ == '__main__':
    main()
