"""記事 jp-pint-sales-system の計算(商いの帳場)。python scripts/figs/peppol_jp_pint_columns.py で再現できる。

1. JP PINT(Peppol BIS Standard Invoice JP PINT、Ver. 1.1.3、2026-06-08 公表)の意味モデルの表を公式の文書サイトから読み、
   必須の項目を数える。数え方は2段:
   (a) 表の多重度が 1..1 か 1..n で、親のグループもすべて必須のもの(親が 0..1 / 0..n なら、そのグループを使うときだけ必須なので数えない)
   (b) ルールの表(aligned と共通のルール)で、日本の課税事業者どうしの通常の請求書なら必ず要るもの。ルールの番号と文言を下の RULES に書き、
       読み込んだルールの表にその番号があるかを確かめる
2. (a)+(b) の各項目を、架空の販売管理のテーブルの列(SCHEMA)に1対1で当て、
   「既存の列/計算で出す/固定値/コード表で変換/足りない列」に分けて数える。
3. 図を public/img/articles/jp-pint-sales-system-{columns,timeline,tables}.svg に書き出す(timeline と tables は下の定数をそのまま)。

使い方: python scripts/figs/peppol_jp_pint_columns.py [--cache DIR]
  --cache を付けると、取得した HTML を DIR に保存し、次からはそれを読む(オフラインで再計算できる)。
標準ライブラリだけ。架空のテーブルは商いの帳場が想定したもので、実在の製品の項目ではない。
"""
import html, json, pathlib, re, sys, urllib.request

sys.stdout.reconfigure(encoding='utf-8')
ROOT = pathlib.Path(__file__).resolve().parents[2]
BASE = 'https://docs.peppol.eu/poac/jp/upcoming/pint-jp/'
PAGES = {
    'semantic': BASE + 'trn-invoice/semantic-model/',
    'aligned': BASE + 'trn-invoice/rule/PINT-jurisdiction-aligned-rules/',
    'shared': BASE + 'trn-invoice/rule/PINT-UBL-validation-preprocessed/',
    'release': BASE + 'release-notes/',
}


def get(name, cache):
    if cache:
        p = pathlib.Path(cache) / f'{name}.html'
        if p.exists():
            return p.read_text(encoding='utf-8')
    req = urllib.request.Request(PAGES[name], headers={'User-Agent': 'Mozilla/5.0'})
    t = urllib.request.urlopen(req, timeout=60).read().decode('utf-8')
    if cache:
        pathlib.Path(cache).mkdir(parents=True, exist_ok=True)
        (pathlib.Path(cache) / f'{name}.html').write_text(t, encoding='utf-8')
    return t


def text_lines(t):
    t = re.sub(r'<script.*?</script>|<style.*?</style>', '', t, flags=re.S)
    t = html.unescape(re.sub(r'<[^>]+>', '\n', t))
    return [re.sub(r'\s+', ' ', l).strip() for l in t.split('\n') if l.strip()]


def semantic_rows(t):
    rows = []
    for rid, ind, body in re.findall(r'<tr id=(\S+) class="[^"]*indented(\d+)[^"]*"\s*>(.*?)</tr>', t, flags=re.S):
        tds = [html.unescape(re.sub(r'<[^>]+>|\s+', ' ', x)).strip() for x in re.findall(r'<td[^>]*>(.*?)</td>', body, flags=re.S)]
        rows.append({'id': tds[1], 'term': tds[2], 'level': int(ind), 'card': tds[4]})
    return rows


def mandatory_by_card(rows):
    stack, out = [], []
    for r in rows:
        stack = stack[:r['level']]
        req = r['card'].startswith('1..') and all(a['card'].startswith('1..') for a in stack)
        if req and r['id'].startswith('IBT'):
            out.append(r['id'])
        stack.append(r)
    return out


# (b) 日本の課税事業者どうしの通常の請求書(税率8%・10%、非課税・輸出なし)で、ルールにより必ず要るもの
RULES = [
    ('IBT-031', 'aligned-ibr-jp-04', '売手の税の識別子(登録番号 T+13桁)'),
    ('IBT-031-1', 'aligned-ibr-jp-03', '税の種類のコード(VAT)'),
    ('IBT-119', 'aligned-ibrp-048', '税率ごとの内訳の税率'),
    ('IBT-152', 'aligned-ibrp-050-jp', '明細の税率'),
    ('IBG-14', 'aligned-ibrp-052', '請求期間か明細の期間'),
]

# 架空の販売管理(インボイス対応済み・国内の卸)。テーブル:自社設定、得意先、商品、請求(締め)、請求明細
SCHEMA = {
    '自社設定': ['社名', '登録番号', '郵便番号', '住所', '電話', '振込先'],
    '得意先': ['得意先コード', '得意先名', '郵便番号', '住所', '電話', '締め日', '端数処理'],
    '商品': ['商品コード', '商品名', '単位(ケース・本などの文字)', '税率', '標準単価'],
    '請求': ['請求番号', '請求日', '得意先コード', '締め期間の開始日', '締め期間の終了日', '税抜合計',
           '8%対象額', '8%税額', '10%対象額', '10%税額', '税込合計', '今回請求額'],
    '請求明細': ['行番号', '売上日', '商品コード', '数量', '単価', '金額', '税率'],
}

# 各項目の当て先:(区分, 当てる列か値)。区分 = 既存の列/計算/固定値/変換/不足
MAP = {
    'IBT-001': ('既存の列', '請求.請求番号'),
    'IBT-002': ('既存の列', '請求.請求日'),
    'IBT-003': ('固定値', '請求書の種類のコード'),
    'IBT-005': ('固定値', 'JPY'),
    'IBT-023': ('固定値', '業務プロセスの識別子'),
    'IBT-024': ('固定値', '仕様の識別子'),
    'IBT-027': ('既存の列', '自社設定.社名'),
    'IBT-034': ('不足', '自社設定.Peppol ID'),
    'IBT-034-1': ('不足', '自社設定.Peppol ID の区分'),
    'IBT-040': ('固定値', 'JP'),
    'IBT-044': ('既存の列', '得意先.得意先名'),
    'IBT-049': ('不足', '得意先.Peppol ID'),
    'IBT-049-1': ('不足', '得意先.Peppol ID の区分'),
    'IBT-055': ('固定値', 'JP(国内の得意先だけの場合)'),
    'IBT-106': ('計算', '請求明細.金額の合計'),
    'IBT-109': ('既存の列', '請求.税抜合計'),
    'IBT-110': ('計算', '請求.8%税額+10%税額'),
    'IBT-112': ('既存の列', '請求.税込合計'),
    'IBT-115': ('既存の列', '請求.今回請求額'),
    'IBT-116': ('既存の列', '請求.8%対象額/10%対象額'),
    'IBT-117': ('既存の列', '請求.8%税額/10%税額'),
    'IBT-118': ('変換', '税率→税区分のコード'),
    'IBT-118-1': ('固定値', 'VAT'),
    'IBT-126': ('既存の列', '請求明細.行番号'),
    'IBT-129': ('既存の列', '請求明細.数量'),
    'IBT-130': ('不足', '商品.単位コード(UN/ECE)'),
    'IBT-131': ('既存の列', '請求明細.金額'),
    'IBT-146': ('既存の列', '請求明細.単価'),
    'IBT-151': ('変換', '税率→税区分のコード'),
    'IBT-153': ('既存の列', '商品.商品名'),
    'IBT-031': ('既存の列', '自社設定.登録番号'),
    'IBT-031-1': ('固定値', 'VAT'),
    'IBT-119': ('既存の列', '請求の税率(8・10)'),
    'IBT-152': ('既存の列', '請求明細.税率'),
    'IBG-14': ('既存の列', '請求.締め期間の開始日・終了日'),
}
# 図(timeline):記事に <img> で置く。数字は本文と同じ
TIMELINE = """<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 720 250' width='720' height='250' role='img' aria-labelledby='bps-tl-t' font-family="'Hiragino Sans','Yu Gothic','Meiryo',sans-serif"><title id='bps-tl-t'>ベルギーと日本の電子インボイスの年表。ベルギーは2024年9月に告知、2025年12月に寛容期間を告知、2026年1月1日に課税事業者どうしで義務化。日本は2021年9月にデジタル庁がOpenPeppolに参加、2023年10月にインボイス制度、2026年6月8日にJP PINT 1.1.3を公表</title><rect width='720' height='250' fill='#fff'/><line x1='100' x2='700' y1='78' y2='78' stroke='#a8a8a8' stroke-width='2'/><line x1='100' x2='700' y1='170' y2='170' stroke='#a8a8a8' stroke-width='2'/><g stroke='#dcdcdc'><line x1='110' x2='110' y1='205' y2='211'/><line x1='214' x2='214' y1='205' y2='211'/><line x1='317' x2='317' y1='205' y2='211'/><line x1='421' x2='421' y1='205' y2='211'/><line x1='524' x2='524' y1='205' y2='211'/><line x1='628' x2='628' y1='205' y2='211'/></g><text x='20' y='83' font-size='14' font-weight='700' fill='#111111'>ベルギー</text><text x='20' y='175' font-size='14' font-weight='700' fill='#111111'>日本</text><g font-size='11' fill='#6b6b6b' text-anchor='middle'><text x='110' y='222'>2021</text><text x='214' y='222'>2022</text><text x='317' y='222'>2023</text><text x='421' y='222'>2024</text><text x='524' y='222'>2025</text><text x='628' y='222'>2026</text></g><circle cx='490' cy='78' r='6' fill='#678b2c'/><text x='490' y='60' font-size='11.5' fill='#444444' text-anchor='middle'>2024年9月 告知</text><circle cx='619' cy='78' r='6' fill='#678b2c'/><text x='625' y='108' font-size='11.5' fill='#444444' text-anchor='end'>2025年12月 寛容期間の告知</text><rect x='627' y='70' width='16' height='16' fill='#c23b22'/><text x='643' y='36' font-size='12.5' font-weight='700' fill='#a3301b' text-anchor='end'>2026年1月1日 課税事業者どうしで義務</text><circle cx='179' cy='170' r='6' fill='#678b2c'/><text x='149' y='152' font-size='11.5' fill='#444444' text-anchor='start'>2021年9月 デジタル庁がOpenPeppolに参加</text><circle cx='395' cy='170' r='6' fill='#678b2c'/><text x='395' y='198' font-size='11.5' fill='#444444' text-anchor='middle'>2023年10月 インボイス制度</text><circle cx='673' cy='170' r='6' fill='#557323'/><text x='683' y='152' font-size='11.5' font-weight='700' fill='#557323' text-anchor='end'>2026年6月8日 JP PINT 1.1.3</text><text x='20' y='244' font-size='11' fill='#6b6b6b'>ベルギー連邦財務省、デジタル庁、JP PINT の文書をもとに商いの帳場が作図(2026年10月16日確認)</text></svg>"""

# 図(tables):記事に <img> で置く。数字は本文と同じ
TABLES = """<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 720 270' width='720' height='270' role='img' aria-labelledby='bps-tb-t' font-family="'Hiragino Sans','Yu Gothic','Meiryo',sans-serif"><title id='bps-tb-t'>架空の販売管理の3つのテーブルに足す5列の図。自社設定にPeppol IDと区分、得意先にPeppol IDと区分、商品に単位コード。請求と請求明細のテーブルには足す列がない</title><rect width='720' height='270' fill='#fff'/><g font-size='12.5' fill='#444444'><rect x='20' y='20' width='210' height='190' rx='6' fill='#fff' stroke='#678b2c' stroke-width='2'/><rect x='20' y='20' width='210' height='32' rx='6' fill='#678b2c'/><text x='125' y='42' font-size='14' font-weight='700' fill='#ffffff' text-anchor='middle'>自社設定</text><text x='36' y='76'>社名・登録番号</text><text x='36' y='98'>住所・電話・振込先</text><rect x='30' y='136' width='190' height='26' fill='#fbeeea' stroke='#c23b22'/><text x='38' y='154' font-weight='700' fill='#a3301b'>+ Peppol ID</text><rect x='30' y='168' width='190' height='26' fill='#fbeeea' stroke='#c23b22'/><text x='38' y='186' font-weight='700' fill='#a3301b'>+ Peppol ID の区分</text><rect x='255' y='20' width='210' height='190' rx='6' fill='#fff' stroke='#678b2c' stroke-width='2'/><rect x='255' y='20' width='210' height='32' rx='6' fill='#678b2c'/><text x='360' y='42' font-size='14' font-weight='700' fill='#ffffff' text-anchor='middle'>得意先</text><text x='271' y='76'>得意先コード・得意先名</text><text x='271' y='98'>住所・締め日・端数処理</text><rect x='265' y='136' width='190' height='26' fill='#fbeeea' stroke='#c23b22'/><text x='273' y='154' font-weight='700' fill='#a3301b'>+ Peppol ID</text><rect x='265' y='168' width='190' height='26' fill='#fbeeea' stroke='#c23b22'/><text x='273' y='186' font-weight='700' fill='#a3301b'>+ Peppol ID の区分</text><rect x='490' y='20' width='210' height='190' rx='6' fill='#fff' stroke='#678b2c' stroke-width='2'/><rect x='490' y='20' width='210' height='32' rx='6' fill='#678b2c'/><text x='595' y='42' font-size='14' font-weight='700' fill='#ffffff' text-anchor='middle'>商品</text><text x='506' y='76'>商品コード・商品名</text><text x='506' y='98'>単位(文字)・税率・標準単価</text><rect x='500' y='136' width='190' height='26' fill='#fbeeea' stroke='#c23b22'/><text x='508' y='154' font-weight='700' fill='#a3301b'>+ 単位コード(H87など)</text></g><text x='20' y='236' font-size='12.5' fill='#444444'>請求(12列)と請求明細(7列)のテーブルには、足す列はありません。</text><text x='20' y='260' font-size='11' fill='#6b6b6b'>JP PINT 1.1.3 の必須項目をもとに商いの帳場が作図。テーブルは架空</text></svg>"""

KINDS = ['既存の列', '計算', '固定値', '変換', '不足']
COLORS = {'既存の列': '#678b2c', '計算': '#9cbb68', '固定値': '#cfd8c0', '変換': '#a8a8a8', '不足': '#c23b22'}


def figure(counts, total, missing):
    W, H = 720, 300
    FONT = "'Hiragino Sans','Yu Gothic','Meiryo',sans-serif"
    b = [f"<text x='30' y='34' font-size='15' font-weight='700' fill='#111111'>JP PINT 1.1.3 の必須{total}項目を、架空の販売管理の列に当てた結果</text>"]
    x, y, bw = 30, 66, 660
    for k in KINDS:
        w = bw * counts[k] / total
        b.append(f"<rect x='{x:.1f}' y='{y}' width='{w:.1f}' height='44' fill='{COLORS[k]}'/>")
        if w > 26:
            fill = '#ffffff' if k in ('既存の列', '不足') else '#111111'
            b.append(f"<text x='{x + w / 2:.1f}' y='{y + 28}' font-size='15' font-weight='700' fill='{fill}' text-anchor='middle'>{counts[k]}</text>")
        x += w
    ly = 140
    for i, k in enumerate(KINDS):
        lx = 30 + i * 134
        b.append(f"<rect x='{lx}' y='{ly}' width='14' height='14' fill='{COLORS[k]}'/>"
                 f"<text x='{lx + 20}' y='{ly + 12}' font-size='12.5' fill='#444444'>{k} {counts[k]}</text>")
    b.append("<text x='30' y='192' font-size='13.5' font-weight='700' fill='#a3301b'>足りない列 " + str(len(missing)) + "つ</text>")
    for i, m in enumerate(missing):
        b.append(f"<text x='{48 + (i % 2) * 330}' y='{216 + (i // 2) * 22}' font-size='12.5' fill='#444444'>{m}</text>")
    b.append(f"<text x='30' y='{H - 14}' font-size='11' fill='#6b6b6b'>docs.peppol.eu の JP PINT 1.1.3 の意味モデルとルールの表をもとに商いの帳場が集計(2026-10-16)。テーブルは架空</text>")
    return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}" width="{W}" height="{H}" font-family="{FONT}">'
            f'<rect width="{W}" height="{H}" fill="#ffffff"/>{"".join(b)}</svg>')


def main():
    cache = sys.argv[sys.argv.index('--cache') + 1] if '--cache' in sys.argv else None
    rel = ' | '.join(text_lines(get('release', cache)))
    version = re.search(r'Version (\d+\.\d+\.\d+) \| Maintained by \| Japan Peppol Team \| Release Date \| (\d{4}-\d{2}-\d{2})', rel)
    rows = semantic_rows(get('semantic', cache))
    by_card = mandatory_by_card(rows)
    rule_lines = text_lines(get('aligned', cache)) + text_lines(get('shared', cache))
    rule_ids = {m.group(1) for l in rule_lines for m in [re.match(r'\[([a-z0-9-]+)\]', l)] if m}
    for _, rid, _ in RULES:
        assert rid in rule_ids, f'ルール {rid} が見つからない'
    items = by_card + [r[0] for r in RULES]
    assert set(items) == set(MAP), f'当て先の表と項目が合わない: {set(items) ^ set(MAP)}'
    counts = {k: sum(1 for i in items if MAP[i][0] == k) for k in KINDS}
    missing = list(dict.fromkeys(MAP[i][1] for i in items if MAP[i][0] == '不足'))
    terms = {r['id']: r['term'] for r in rows}
    out = {
        'version': version.groups() if version else None,
        'semanticRows': len(rows),
        'businessTerms': sum(1 for r in rows if r['id'].startswith('IBT')),
        'mandatoryByCardinality': len(by_card),
        'mandatoryByRule': len(RULES),
        'total': len(items),
        'counts': counts,
        'missingColumns': missing,
        'schemaColumns': sum(len(v) for v in SCHEMA.values()),
        'items': [{'id': i, 'term': terms.get(i, ''), 'kind': MAP[i][0], 'to': MAP[i][1]} for i in items],
    }
    print(json.dumps(out, ensure_ascii=False, indent=1))
    dst = ROOT / 'public/img/articles/jp-pint-sales-system-columns.svg'
    dst.write_text(figure(counts, len(items), missing), encoding='utf-8')
    for name, svg in (('timeline', TIMELINE), ('tables', TABLES)):
        (ROOT / f'public/img/articles/jp-pint-sales-system-{name}.svg').write_text(svg, encoding='utf-8')


if __name__ == '__main__':
    main()
