#!/usr/bin/env python3
"""図 1〜7 を SVG で生成する（日本語・英語）。

出力: figures/figNN_<slug>.svg（ja）, figures/figNN_<slug>.en.svg（en）
配色はサイトの技術図解（i3-cyber-whitepaper.css）と同じ: 緑 #2f7a4d / 青 #0071e3 / 赤 #c9342a / 灰 #6e6e73。
標準ライブラリのみ。図 6・7 は model/cost_model.py の計算結果を使う。
"""

from __future__ import annotations

import html
import math
import sys
from pathlib import Path

HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
import cost_model as cm  # noqa: E402

FIG = HERE.parent / "figures"
FIG.mkdir(exist_ok=True)

GREEN, BLUE, RED, GRAY, TEXT = "#2f7a4d", "#0071e3", "#c9342a", "#6e6e73", "#1d1d1f"
GREEN_SOFT, BLUE_SOFT, RED_SOFT, GRAY_SOFT = "#eaf7ef", "#f0f4ff", "#fde8e8", "#f5f5f7"


class SVG:
    def __init__(self, w: int, h: int, title: str, desc: str, fid: str):
        self.w, self.h = w, h
        self.parts = [
            f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" role="img" aria-labelledby="{fid}-t {fid}-d" class="i3wp-chart">',
            f'<title id="{fid}-t">{html.escape(title)}</title>',
            f'<desc id="{fid}-d">{html.escape(desc)}</desc>',
            '<defs><marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="context-stroke"/></marker>'
            '<marker id="arr-red" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#c9342a"/></marker>'
            '<marker id="arr-blue" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#0071e3"/></marker>'
            '<marker id="arr-gray" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#6e6e73"/></marker>'
            '<marker id="arr-green" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#2f7a4d"/></marker></defs>',
            f'<rect x="0" y="0" width="{w}" height="{h}" fill="#ffffff" rx="10"/>',
        ]

    def box(self, x, y, w, h, label, fill=GRAY_SOFT, stroke=GRAY, size=12, bold=False, sub=None, dash=False, rx=10):
        d = ' stroke-dasharray="6 4"' if dash else ""
        self.parts.append(f'<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="{stroke}" stroke-width="1.4" rx="{rx}"{d}/>')
        lines = label.split("\n")
        n = len(lines) + (1 if sub else 0)
        cy = y + h / 2 - (n - 1) * (size + 3) / 2
        for i, ln in enumerate(lines):
            fw = ' font-weight="700"' if bold else ""
            self.parts.append(f'<text x="{x + w/2}" y="{cy + i*(size+3) + size*0.35:.1f}" text-anchor="middle" font-size="{size}" fill="{TEXT}"{fw}>{html.escape(ln)}</text>')
        if sub:
            self.parts.append(f'<text x="{x + w/2}" y="{cy + len(lines)*(size+3) + size*0.35:.1f}" text-anchor="middle" font-size="{size-2}" fill="{GRAY}">{html.escape(sub)}</text>')

    def text(self, x, y, s, size=12, fill=TEXT, anchor="start", bold=False, italic=False):
        fw = ' font-weight="700"' if bold else ""
        fs = ' font-style="italic"' if italic else ""
        self.parts.append(f'<text x="{x}" y="{y}" text-anchor="{anchor}" font-size="{size}" fill="{fill}"{fw}{fs}>{html.escape(s)}</text>')

    def arrow(self, x1, y1, x2, y2, color=GRAY, width=1.6, dash=False, marker=True):
        mk = {RED: "arr-red", BLUE: "arr-blue", GREEN: "arr-green"}.get(color, "arr-gray")
        d = ' stroke-dasharray="5 4"' if dash else ""
        m = f' marker-end="url(#{mk})"' if marker else ""
        self.parts.append(f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{color}" stroke-width="{width}"{d}{m}/>')

    def path(self, d, color=GRAY, width=1.6, dash=False, marker=True):
        mk = {RED: "arr-red", BLUE: "arr-blue", GREEN: "arr-green"}.get(color, "arr-gray")
        dd = ' stroke-dasharray="5 4"' if dash else ""
        m = f' marker-end="url(#{mk})"' if marker else ""
        self.parts.append(f'<path d="{d}" fill="none" stroke="{color}" stroke-width="{width}"{dd}{m}/>')

    def rect(self, x, y, w, h, fill="none", stroke=GRAY, dash=True, rx=12, width=1.4):
        d = ' stroke-dasharray="7 5"' if dash else ""
        self.parts.append(f'<rect x="{x}" y="{y}" width="{w}" height="{h}" fill="{fill}" stroke="{stroke}" stroke-width="{width}" rx="{rx}"{d}/>')

    def cross(self, x, y, r=7, color=RED):
        self.parts.append(f'<circle cx="{x}" cy="{y}" r="{r+4}" fill="#fff" stroke="{color}" stroke-width="1.6"/>')
        self.parts.append(f'<line x1="{x-r*0.6}" y1="{y-r*0.6}" x2="{x+r*0.6}" y2="{y+r*0.6}" stroke="{color}" stroke-width="2.2"/>')
        self.parts.append(f'<line x1="{x-r*0.6}" y1="{y+r*0.6}" x2="{x+r*0.6}" y2="{y-r*0.6}" stroke="{color}" stroke-width="2.2"/>')

    def dump(self) -> str:
        return "\n".join(self.parts + ["</svg>"])


L = {
    "ja": {
        "attacker": "攻撃者", "mfg": "製造工程", "dev": "ソフトウェア\n開発工程", "update": "更新配信サーバー\n署名鍵", "cloud": "メーカーの\nクラウド制御面",
        "net": "通信経路\n（インターネット・携帯網）", "admin": "管理者アカウント\n（保守・遠隔監視）", "site": "現場ネットワーク", "device": "現場設備\nPCS・蓄電池・EV充電器",
        "scope1": "JC-STAR★1 が評価する範囲（製品の機能）", "scope_out": "評価の対象外", "same_target": "どの経路からでも、同じ設備に同じ指令が届く",
        "f1_title": "図1 製造から運用までの攻撃経路", "f1_desc": "攻撃者は製造工程・開発工程・更新配信サーバー・クラウド制御面・通信経路・管理者アカウント・現場ネットワークのいずれからでも現場設備に到達できる。JC-STAR★1 が評価するのは現場設備（製品）の機能だけで、他の到達点は対象外。",
        "f2_title": "図2 製造国と攻撃経路の非一致", "f2_desc": "製造国・開発国・クラウド所在地・攻撃者所在地・運用国は別々の場所にあり得る。攻撃者は製造国の製品や企業を経由せず、第三国のクラウド、正規の管理アカウント、侵害済みの踏み台から日本の運用システムに到達できる。",
        "c_mfg": "製造国", "c_dev": "開発国", "c_cloud": "クラウド所在地", "c_atk": "攻撃者所在地", "c_ops": "運用国（日本）",
        "n_factory": "工場・部品", "n_devteam": "開発チーム・委託先", "n_cloudreg": "データセンター\n（第三国のリージョン）", "n_attacker": "攻撃主体\n国籍・所属は\n外形から判定不能", "n_relay": "侵害済みの踏み台\n（第三国の企業・個人）",
        "n_opsys": "運用中のシステム\nPCS・EMS\n監視・保守端末", "n_admin": "正規の管理アカウント", "f2_note": "原産国の確認で分かるのは左の二列だけ。侵入経路は右の三列から来る。",
        "f3_title": "図3 工程審査が見る範囲と、運用中に残る攻撃面", "f3_desc": "製造・開発・納入・設置・運用・更新の各段階を横軸に置く。製造・開発段階の審査が対象とする範囲は左側に限られ、運用・更新段階にはクラウド、管理アカウント、通信経路、未知の脆弱性、更新配信という攻撃面が残る。",
        "st": ["製造", "開発", "納入", "設置", "運用", "更新"], "audit": "製造・開発工程の審査が見る範囲", "cert_point": "★1 適合（ある時点の製品機能）",
        "resid": ["クラウド制御面", "管理者アカウント", "通信経路・露出", "公開後の未知の脆弱性", "更新配信・署名鍵", "保守端末・現地協力者"], "resid_t": "運用中に残る攻撃面（審査の対象外）", "lifetime": "設備寿命 15〜20 年",
        "f4_title": "図4 製品認証中心モデルと境界防護中心モデル", "f4_desc": "左は製品認証中心: 信頼境界は製品一台ごとにあり、クラウドと更新配信が全台に直結する共通障害点。右は境界防護中心: 信頼境界はサイト境界にあり、クラウドからの指令は境界で検証され、共通障害点は境界装置の型式に移る。",
        "modelA": "A 製品認証中心", "modelB": "B 境界防護中心", "tb": "信頼境界", "cfp": "共通障害点", "cert_dev": "認証済み\n機器", "boundary": "境界装置\n許可リスト・指令検証・速度制限", "ems": "ローカル EMS\n出力の床・自律運転",
        "a_cfp": "クラウド・更新配信 → 全台へ直結", "b_cfp": "境界装置の型式 → 型式を分ける・段階更新", "spread": "侵害時の波及: 制御面に属する全台", "spread_b": "侵害時の波及: 境界が働かないサイトのみ",
        "f5_title": "図5 境界防護を備えた分散型電源システム", "f5_desc": "インターネット上の複数メーカーのクラウドと充電管理システム（CSMS）からの通信は、サイト境界の装置で宛先・プロトコル・ファンクションコード・頻度を検証される。境界の内側ではローカル EMS が出力の床と自律運転を保ち、異なるメーカーの PCS・蓄電池・OCPP 充電器を同じ境界が守る。系統運用者の指令経路はメーカー保守経路と分ける。",
        "internet": "インターネット", "cloudA": "メーカー A\nクラウド", "cloudB": "メーカー B\nクラウド", "csms": "充電管理\nCSMS（OCPP）", "tso": "系統運用者\n出力制御・指令",
        "bdev": "境界装置", "bdev_sub": "許可リスト／プロトコル検証／書込ファンクション制限／速度制限／ログ", "lems": "ローカル EMS", "lems_sub": "出力の床・通信断時の既定動作・権限上限",
        "pcsA": "PCS A社", "pcsB": "PCS B社", "bess": "蓄電池 C社", "evse": "EV充電器\nOCPP", "allowed": "許可された読取・限定された書込", "blocked": "一斉停止・設定変更・未知の宛先",
        "f6_title": "図6 攻撃時に同時に動く容量（条件付き）", "f6_desc": "フリート 10,000 MW、クラウド制御面の占有率 25%、境界装置の実効率 0.9 という仮定での比較。A はクラウド 1 件の侵害で 2,500 MW が同時に動く。B は境界が働かない 1 割のサイトのみ 250 MW。B 固有の共通原因（境界装置型式の脆弱性）は全サイトが同一型式なら上限はフリート全体の 10,000 MW、型式を 5 に分けると 2,000 MW に下がる。",
        "f6_bars": ["A: クラウド制御面 1 件の侵害", "B: 同じ侵害（境界 ρ=0.9）", "B 固有: 境界装置が全サイト同一型式（上限）", "B 固有: 境界装置 5 型式に分散"], "mw": "MW（同時に動く容量の上限）", "fleet": "フリート {fleet:,.0f} MW・占有率 {sv:.0%}・ρ={rho}・仮定値",
        "f7_title": "図7 製品認証方式と境界防護方式の 1 kW あたり年間費用（対数軸・仮定値）", "f7_desc": "横軸はサイト規模 4 区分、縦軸は 1 kW あたりの年間費用（円、対数）。青が製品認証方式 A（認証費用の価格転嫁＋交換遅延の逸失電力）、緑が境界防護方式 B（境界装置の年割り＋運用費）。細線は low〜high の幅。",
        "f7_legA": "A 製品認証方式（認証費用の転嫁＋同型式交換が重くなる分の逸失電力）", "f7_legB": "B 境界防護方式（境界装置の年割り＋運用費＋短い交換遅延）　細線: low〜high", "f7_axis": "円 / kW / 年（対数）",
        "f7_note": "入力はすべて data/assumptions.csv の仮定値。有効期間 2 年は IPA 公表値。申請手数料（kind=to-confirm）は公表値で置き換える。",
        "sites": {"lv_res": "低圧・住宅（5 kW）", "lv_biz": "低圧・事業用（49.5 kW）", "hv": "高圧（500 kW）", "ehv": "特別高圧（5,000 kW）"},
    },
    "en": {
        "attacker": "Attacker", "mfg": "Manufacturing", "dev": "Software\ndevelopment", "update": "Update server\nsigning keys", "cloud": "Vendor cloud\ncontrol plane",
        "net": "Communication path\n(Internet, cellular)", "admin": "Admin accounts\n(maintenance, remote monitoring)", "site": "Site network", "device": "Field equipment\nPCS, battery, EV charger",
        "scope1": "What JC-STAR★1 evaluates (product functions)", "scope_out": "Out of scope", "same_target": "The same command reaches the same equipment from any of these paths",
        "f1_title": "Fig. 1 Attack paths from manufacturing to operation", "f1_desc": "An attacker can reach field equipment from manufacturing, software development, update servers, the vendor cloud control plane, communication paths, admin accounts or the site network. JC-STAR★1 evaluates only the functions of the field equipment; every other point of reach is out of scope.",
        "f2_title": "Fig. 2 Country of manufacture and attack path do not coincide", "f2_desc": "Country of manufacture, country of development, cloud location, attacker location and country of operation can all differ. An attacker reaches the operating system in Japan without touching the manufacturing country's products or companies: via third-country clouds, legitimate admin accounts and compromised relays.",
        "c_mfg": "Manufactured in", "c_dev": "Developed in", "c_cloud": "Cloud hosted in", "c_atk": "Attacker located in", "c_ops": "Operated in (Japan)",
        "n_factory": "Factory, components", "n_devteam": "Dev team, contractors", "n_cloudreg": "Data centre\n(third-country region)", "n_attacker": "Threat actor\nnationality and\naffiliation not visible", "n_relay": "Compromised relay\n(third-country firm or person)",
        "n_opsys": "System in operation\nPCS, EMS,\nmonitoring, laptops", "n_admin": "Legitimate admin account", "f2_note": "Origin checks tell you about the two left columns. Intrusions come from the three on the right.",
        "f3_title": "Fig. 3 What process audits see, and the attack surface left in operation", "f3_desc": "Stages manufacturing, development, delivery, installation, operation and update on the horizontal axis. Audits of manufacturing and development cover only the left; in operation and update the cloud, admin accounts, communication paths, undisclosed vulnerabilities and update delivery remain.",
        "st": ["Manufacture", "Develop", "Deliver", "Install", "Operate", "Update"], "audit": "Scope of manufacturing / development audits", "cert_point": "★1 conformity (product functions at a point in time)",
        "resid": ["Cloud control plane", "Admin accounts", "Comm paths, exposure", "Vulnerabilities disclosed later", "Update delivery, signing keys", "Service laptops, local contractors"], "resid_t": "Attack surface remaining in operation (not audited)", "lifetime": "Asset life 15–20 years",
        "f4_title": "Fig. 4 Product-certification-centric vs boundary-protection-centric model", "f4_desc": "Left, product certification: the trust boundary sits on each device; cloud and update delivery connect directly to every unit and form the common point of failure. Right, boundary protection: the trust boundary sits at the site; cloud commands are validated at the boundary and the common point of failure moves to the boundary device model.",
        "modelA": "A Product-certification-centric", "modelB": "B Boundary-protection-centric", "tb": "Trust boundary", "cfp": "Common point of failure", "cert_dev": "Certified\ndevice", "boundary": "Boundary device\nallow-list, command validation, rate limit", "ems": "Local EMS\noutput floor, autonomy",
        "a_cfp": "Cloud and update delivery → direct to every unit", "b_cfp": "Boundary device model → diversify models, staged updates", "spread": "Spread on compromise: every unit on the control plane", "spread_b": "Spread on compromise: only sites where the boundary fails",
        "f5_title": "Fig. 5 A DER system with boundary protection", "f5_desc": "Traffic from several vendor clouds and a charging management system (CSMS) on the Internet is checked at the site boundary for destination, protocol, function code and rate. Inside, a local EMS keeps an output floor and autonomous operation; one boundary protects PCS, batteries and OCPP chargers from different vendors. The grid operator's command path is separated from vendor maintenance paths.",
        "internet": "Internet", "cloudA": "Vendor A\ncloud", "cloudB": "Vendor B\ncloud", "csms": "Charging mgmt\nCSMS (OCPP)", "tso": "Grid operator\ncurtailment, commands",
        "bdev": "Boundary device", "bdev_sub": "allow-list / protocol validation / write-function limits / rate limit / logging", "lems": "Local EMS", "lems_sub": "output floor, default behaviour on comms loss, authority caps",
        "pcsA": "PCS vendor A", "pcsB": "PCS vendor B", "bess": "Battery vendor C", "evse": "EV charger\nOCPP", "allowed": "Permitted reads, limited writes", "blocked": "Mass stop, setting changes, unknown destinations",
        "f6_title": "Fig. 6 Capacity moved at once under attack (conditional)", "f6_desc": "Comparison under assumptions: fleet 10,000 MW, one cloud control plane holding 25%, boundary effectiveness 0.9. A: one cloud compromise moves 2,500 MW at once. B: only the 10% of sites whose boundary fails, 250 MW. B-specific common cause (a vulnerability in the boundary device model): with a single model across all sites the ceiling is the whole 10,000 MW fleet; five models lower it to 2,000 MW.",
        "f6_bars": ["A: one cloud control-plane compromise", "B: same compromise (boundary ρ=0.9)", "B-specific: one boundary model on all sites (ceiling)", "B-specific: five boundary models"], "mw": "MW (ceiling of capacity moved at once)", "fleet": "Fleet {fleet:,.0f} MW, share {sv:.0%}, ρ={rho}, assumptions",
        "f7_title": "Fig. 7 Annual cost per kW, product certification vs boundary protection (log scale, assumptions)", "f7_desc": "Four site classes on the horizontal axis; annual cost per kW (JPY, log) on the vertical. Blue: product certification A (pass-through of certification cost plus lost generation from replacement delay). Green: boundary protection B (annualised boundary device plus operations). Thin lines: low–high range.",
        "f7_legA": "A Product certification (cost pass-through + lost output when like-for-like replacement gets heavier)", "f7_legB": "B Boundary protection (annualised device + operations + short replacement delay)   thin line: low–high", "f7_axis": "JPY / kW / year (log)",
        "f7_note": "All inputs are assumptions in data/assumptions.csv. Validity (2 years) is IPA's published value; the application fee (kind=to-confirm) is to be replaced by the published figure.",
        "sites": {"lv_res": "LV residential (5 kW)", "lv_biz": "LV commercial (49.5 kW)", "hv": "HV (500 kW)", "ehv": "EHV (5,000 kW)"},
    },
}


# ---------------------------------------------------------------- fig 1

def fig01(t):
    s = SVG(760, 440, t["f1_title"], t["f1_desc"], "fig1")
    s.box(20, 185, 90, 70, t["attacker"], fill=RED_SOFT, stroke=RED, bold=True)
    nodes = [t["mfg"], t["dev"], t["update"], t["cloud"], t["net"], t["admin"], t["site"]]
    ys = [30, 85, 140, 195, 250, 305, 360]
    for y, lab in zip(ys, nodes):
        s.box(230, y, 230, 46, lab, fill=GRAY_SOFT, stroke=GRAY, size=11.5)
        s.arrow(110, 220, 230, y + 23, color=RED, width=1.3)
        s.arrow(460, y + 23, 580, 220, color=RED, width=1.3)
    s.rect(566, 160, 178, 120, fill=GREEN_SOFT, stroke=GREEN, dash=True)
    s.box(580, 185, 150, 70, t["device"], fill="#fff", stroke=GREEN, size=11.5, bold=True)
    s.text(655, 152, t["scope1"], size=10.5, fill=GREEN, anchor="middle", bold=True)
    s.text(345, 425, t["scope_out"], size=11, fill=RED, anchor="middle", bold=True)
    s.text(380, 18, t["same_target"], size=11.5, fill=GRAY, anchor="middle")
    return s.dump()


# ---------------------------------------------------------------- fig 2 (図A)

def fig02(t):
    s = SVG(760, 420, t["f2_title"], t["f2_desc"], "fig2")
    cols = [t["c_mfg"], t["c_dev"], t["c_cloud"], t["c_atk"], t["c_ops"]]
    xs = [20, 168, 316, 464, 612]
    fills = [BLUE_SOFT, BLUE_SOFT, GRAY_SOFT, RED_SOFT, GREEN_SOFT]
    strokes = [BLUE, BLUE, GRAY, RED, GREEN]
    for x, c, f, st in zip(xs, cols, fills, strokes):
        s.rect(x, 20, 128, 380, fill=f, stroke=st, dash=False, rx=12, width=1)
        s.text(x + 64, 42, c, size=12, fill=st, anchor="middle", bold=True)
    s.box(30, 70, 108, 56, t["n_factory"], fill="#fff", stroke=BLUE, size=11)
    s.box(178, 70, 108, 56, t["n_devteam"], fill="#fff", stroke=BLUE, size=11)
    s.box(326, 140, 108, 64, t["n_cloudreg"], fill="#fff", stroke=GRAY, size=11)
    s.box(474, 60, 108, 72, t["n_attacker"], fill="#fff", stroke=RED, size=10.5, bold=True)
    s.box(474, 250, 108, 64, t["n_relay"], fill="#fff", stroke=RED, size=10.5)
    s.box(622, 120, 108, 100, t["n_opsys"], fill="#fff", stroke=GREEN, size=10.5, bold=True)
    s.box(622, 270, 108, 46, t["n_admin"], fill="#fff", stroke=GREEN, size=10.5)
    # supply chain (blue, thin, dashed): manufacturing/dev -> ops
    s.path("M138,98 C 150,98 165,98 178,98", color=BLUE, dash=True)
    s.path("M286,98 C 400,98 560,120 622,150", color=BLUE, dash=True)
    # attack paths (red)
    s.path("M474,96 C 440,120 440,150 434,172", color=RED)            # attacker -> third-country cloud
    s.path("M434,172 C 520,172 580,170 622,170", color=RED)            # cloud -> ops
    s.path("M528,132 C 528,180 528,220 528,250", color=RED)            # attacker -> relay
    s.path("M582,282 C 600,282 610,290 622,293", color=RED)            # relay -> admin account
    s.path("M676,270 C 676,250 676,240 676,220", color=RED)            # admin -> ops
    s.path("M582,80 C 640,70 700,80 700,120", color=RED)               # attacker direct -> ops (exposed)
    s.text(380, 390, t["f2_note"], size=11, fill=TEXT, anchor="middle")
    return s.dump()


# ---------------------------------------------------------------- fig 3 (図B)

def fig03(t):
    s = SVG(760, 400, t["f3_title"], t["f3_desc"], "fig3")
    st = t["st"]
    x0, w = 30, 115
    y_axis = 300
    s.arrow(x0, y_axis, x0 + w * 6 + 20, y_axis, color=GRAY, width=1.6)
    for i, lab in enumerate(st):
        cx = x0 + w * i + w / 2
        s.box(x0 + w * i + 6, y_axis - 44, w - 12, 36, lab, fill=GRAY_SOFT, stroke=GRAY, size=12, bold=True)
    # audit scope band
    s.rect(x0 + 2, 60, w * 2 - 4, 190, fill=BLUE_SOFT, stroke=BLUE, dash=True)
    s.text(x0 + w, 84, t["audit"], size=11.5, fill=BLUE, anchor="middle", bold=True)
    s.box(x0 + 20, 110, w * 2 - 40, 44, t["cert_point"], fill="#fff", stroke=BLUE, size=10.5)
    # residual attack surface above operation/update
    rx = x0 + w * 3 + 6
    s.rect(rx, 50, w * 3 - 12, 200, fill=RED_SOFT, stroke=RED, dash=True)
    s.text(rx + (w * 3 - 12) / 2, 74, t["resid_t"], size=11.5, fill=RED, anchor="middle", bold=True)
    for i, r in enumerate(t["resid"]):
        col, row = i % 2, i // 2
        s.box(rx + 12 + col * ((w * 3 - 36) / 2 + 12), 90 + row * 50, (w * 3 - 36) / 2, 40, r, fill="#fff", stroke=RED, size=10.5)
    s.arrow(x0 + w * 4, 330, x0 + w * 6 + 10, 330, color=GRAY, width=1.2, dash=True)
    s.text(x0 + w * 5, 350, t["lifetime"], size=11, fill=GRAY, anchor="middle")
    return s.dump()


# ---------------------------------------------------------------- fig 4 (図2 比較)

def fig04(t):
    s = SVG(760, 460, t["f4_title"], t["f4_desc"], "fig4")
    # panel A
    s.rect(15, 15, 355, 430, fill="#fff", stroke=GRAY, dash=False, rx=14, width=1)
    s.text(192, 40, t["modelA"], size=13, fill=TEXT, anchor="middle", bold=True)
    s.box(110, 60, 165, 50, t["cloud"], fill=RED_SOFT, stroke=RED, size=11)
    s.box(110, 120, 165, 44, t["update"], fill=RED_SOFT, stroke=RED, size=11)
    for i in range(5):
        x = 40 + i * 64
        s.rect(x - 6, 224, 60, 70, fill=GREEN_SOFT, stroke=GREEN, dash=True, rx=10)
        s.box(x, 236, 48, 46, t["cert_dev"], fill="#fff", stroke=GREEN, size=10)
        s.arrow(192, 164, x + 24, 224, color=RED, width=1.2)
    s.text(192, 318, t["tb"] + " = " + ("製品一台ごと" if "A 製品" in t["modelA"] else "each device"), size=11, fill=GREEN, anchor="middle")
    s.text(192, 345, t["cfp"] + ":", size=11, fill=RED, anchor="middle", bold=True)
    s.text(192, 363, t["a_cfp"], size=10.5, fill=RED, anchor="middle")
    s.text(192, 395, t["spread"], size=10.5, fill=TEXT, anchor="middle")
    # panel B
    s.rect(390, 15, 355, 430, fill="#fff", stroke=GRAY, dash=False, rx=14, width=1)
    s.text(567, 40, t["modelB"], size=13, fill=TEXT, anchor="middle", bold=True)
    s.box(485, 60, 165, 50, t["cloud"], fill=GRAY_SOFT, stroke=GRAY, size=11)
    s.box(485, 120, 165, 44, t["update"], fill=GRAY_SOFT, stroke=GRAY, size=11)
    s.rect(405, 178, 325, 130, fill=GREEN_SOFT, stroke=GREEN, dash=True, rx=12)
    s.box(445, 186, 245, 44, t["boundary"], fill="#fff", stroke=GREEN, size=10.5, bold=True)
    s.box(475, 238, 185, 40, t["ems"], fill="#fff", stroke=GREEN, size=10.5)
    s.arrow(567, 164, 567, 186, color=RED, width=1.6)
    s.cross(567, 175)
    for i in range(5):
        x = 415 + i * 64
        s.box(x, 320, 48, 46, t["cert_dev"].split("\n")[-1] if "\n" in t["cert_dev"] else t["cert_dev"], fill="#fff", stroke=GRAY, size=10)
        s.arrow(567, 278, x + 24, 320, color=GREEN, width=1.1)
    s.text(567, 318 + 72, t["tb"] + " = " + ("サイト境界" if "A 製品" in t["modelA"] else "site boundary"), size=11, fill=GREEN, anchor="middle")
    s.text(567, 408, t["cfp"] + ": " + t["b_cfp"], size=10.5, fill=RED, anchor="middle")
    s.text(567, 430, t["spread_b"], size=10.5, fill=TEXT, anchor="middle")
    return s.dump()


# ---------------------------------------------------------------- fig 5 (図3/図C)

def fig05(t):
    s = SVG(760, 520, t["f5_title"], t["f5_desc"], "fig5")
    s.rect(15, 15, 730, 120, fill=GRAY_SOFT, stroke=GRAY, dash=False, rx=14, width=1)
    s.text(40, 38, t["internet"], size=12, fill=GRAY, bold=True)
    s.box(40, 55, 130, 60, t["cloudA"], fill="#fff", stroke=GRAY, size=11)
    s.box(200, 55, 130, 60, t["cloudB"], fill="#fff", stroke=GRAY, size=11)
    s.box(360, 55, 130, 60, t["csms"], fill="#fff", stroke=GRAY, size=11)
    s.box(560, 55, 160, 60, t["tso"], fill=BLUE_SOFT, stroke=BLUE, size=11)
    # boundary
    s.rect(15, 160, 730, 340, fill=GREEN_SOFT, stroke=GREEN, dash=True, rx=14)
    s.box(40, 175, 680, 58, t["bdev"], fill="#fff", stroke=GREEN, size=12.5, bold=True, sub=t["bdev_sub"])
    for x in (105, 265, 425, 640):
        s.arrow(x, 115, x, 175, color=RED if x != 640 else BLUE, width=1.6)
    s.text(300, 152, t["blocked"], size=10.5, fill=RED, anchor="middle")
    s.text(640, 152, t["allowed"], size=10.5, fill=BLUE, anchor="middle")
    s.box(220, 258, 320, 54, t["lems"], fill="#fff", stroke=GREEN, size=12, bold=True, sub=t["lems_sub"])
    s.arrow(380, 233, 380, 258, color=GREEN, width=1.6)
    devs = [t["pcsA"], t["pcsB"], t["bess"], t["evse"]]
    for i, d in enumerate(devs):
        x = 50 + i * 170
        s.box(x, 380, 140, 60, d, fill="#fff", stroke=GRAY, size=11.5)
        s.arrow(380, 312, x + 70, 380, color=GREEN, width=1.2)
    s.text(380, 475, t["allowed"] + " → " + t["lems"].replace("\n", " "), size=10.5, fill=GREEN, anchor="middle")
    return s.dump()


# ---------------------------------------------------------------- fig 6 (図4 被害範囲)

def fig06(t):
    br = {r["case"]: r for r in cm.blast_radius()}
    base = br["base"]
    vals = [base["A_cloud_compromise_mw"], base["B_cloud_compromise_mw"], br["boundary_vendors_1"]["B_boundary_common_mode_mw"], br["boundary_vendors_5"]["B_boundary_common_mode_mw"]]
    colors = [BLUE, GREEN, RED, RED]
    s = SVG(760, 330, t["f6_title"], t["f6_desc"], "fig6")
    ml, mt, pw = 300, 40, 400
    vmax = max(vals) * 1.15
    for i, (lab, v, c) in enumerate(zip(t["f6_bars"], vals, colors)):
        y = mt + i * 58
        s.text(ml - 12, y + 24, lab, size=11.5, fill=TEXT, anchor="end")
        bw = v / vmax * pw
        s.parts.append(f'<rect x="{ml}" y="{y}" width="{bw:.1f}" height="36" fill="{c}" opacity="0.85" rx="6"/>')
        s.text(ml + bw + 8, y + 24, f"{v:,.0f} MW", size=12, fill=c, bold=True)
    s.arrow(ml, mt + 4 * 58, ml + pw + 30, mt + 4 * 58, color=GRAY)
    s.text(ml + pw / 2, mt + 4 * 58 + 22, t["mw"], size=11, fill=GRAY, anchor="middle")
    s.text(380, 318, t["fleet"].format(fleet=base["fleet_mw"], sv=cm.p("share_vendor_cloud"), rho=cm.p("rho_boundary")), size=10.5, fill=GRAY, anchor="middle")
    return s.dump()


# ---------------------------------------------------------------- fig 7 (費用)

def fig07(t):
    rows = cm.cost_table()
    base = [r for r in rows if r["case"] == "base"]
    low = {r["site_class"]: r for r in rows if r["case"] == "low"}
    high = {r["site_class"]: r for r in rows if r["case"] == "high"}
    W, H = 760, 420
    ml, mr, mt, mb = 70, 20, 60, 70
    pw, ph = W - ml - mr, H - mt - mb
    vals = []
    for d in (base, list(low.values()), list(high.values())):
        for r in d:
            vals += [r["A_total_year_per_kw"], r["B_total_year_per_kw"]]
    vmin = max(1.0, min(v for v in vals if v > 0) / 2)
    vmax = max(vals) * 2
    lo, hi = math.log10(vmin), math.log10(vmax)

    def y(v):
        v = max(v, vmin)
        return mt + ph - (math.log10(v) - lo) / (hi - lo) * ph

    s = SVG(W, H, t["f7_title"], t["f7_desc"], "fig7")
    for e in range(math.ceil(lo), math.floor(hi) + 1):
        yy = y(10 ** e)
        s.parts.append(f'<line x1="{ml}" y1="{yy:.1f}" x2="{W-mr}" y2="{yy:.1f}" stroke="#e5e5ea" stroke-width="1"/>')
        s.text(ml - 8, yy + 4, f"{10**e:,.0f}", size=11, fill=GRAY, anchor="end")
    s.parts.append(f'<text x="18" y="{mt+ph/2:.0f}" text-anchor="middle" font-size="11" fill="{GRAY}" transform="rotate(-90 18 {mt+ph/2:.0f})">{html.escape(t["f7_axis"])}</text>')
    n = len(base)
    gw = pw / n
    bw = gw * 0.28
    for i, r in enumerate(base):
        cx = ml + gw * i + gw / 2
        sc = r["site_class"]
        for j, (key, color) in enumerate((("A_total_year_per_kw", BLUE), ("B_total_year_per_kw", GREEN))):
            x0 = cx - bw - 4 if j == 0 else cx + 4
            v = r[key]
            top = y(v)
            s.parts.append(f'<rect x="{x0:.1f}" y="{top:.1f}" width="{bw:.1f}" height="{mt+ph-top:.1f}" fill="{color}" opacity="0.85" rx="3"/>')
            vl, vh = low[sc][key], high[sc][key]
            xm = x0 + bw / 2
            s.parts.append(f'<line x1="{xm:.1f}" y1="{y(vl):.1f}" x2="{xm:.1f}" y2="{y(vh):.1f}" stroke="{TEXT}" stroke-width="1.2"/>')
            s.parts.append(f'<line x1="{xm-4:.1f}" y1="{y(vl):.1f}" x2="{xm+4:.1f}" y2="{y(vl):.1f}" stroke="{TEXT}" stroke-width="1.2"/>')
            s.parts.append(f'<line x1="{xm-4:.1f}" y1="{y(vh):.1f}" x2="{xm+4:.1f}" y2="{y(vh):.1f}" stroke="{TEXT}" stroke-width="1.2"/>')
            s.text(xm, min(top, y(vh)) - 7, f"{v:,.0f}", size=10.5, fill=color, anchor="middle")
        s.text(cx, mt + ph + 20, t["sites"][sc], size=11.5, fill=TEXT, anchor="middle")
    lx, ly = ml, mt - 40
    s.parts.append(f'<rect x="{lx}" y="{ly}" width="14" height="14" fill="{BLUE}" rx="3"/>')
    s.text(lx + 20, ly + 12, t["f7_legA"], size=11)
    s.parts.append(f'<rect x="{lx}" y="{ly+18}" width="14" height="14" fill="{GREEN}" rx="3"/>')
    s.text(lx + 20, ly + 30, t["f7_legB"], size=11)
    s.text(W / 2, H - 14, t["f7_note"], size=10.5, fill=GRAY, anchor="middle")
    return s.dump()


FIGS = [
    ("fig01_attack_paths", fig01),
    ("fig02_origin_vs_attack_path", fig02),
    ("fig03_audit_scope_vs_operation", fig03),
    ("fig04_cert_vs_boundary_models", fig04),
    ("fig05_boundary_protected_der", fig05),
    ("fig06_blast_radius", fig06),
    ("fig07_cost_comparison", fig07),
]


def main() -> None:
    for lang in ("ja", "en"):
        t = L[lang]
        for slug, fn in FIGS:
            name = f"{slug}.svg" if lang == "ja" else f"{slug}.en.svg"
            (FIG / name).write_text(fn(t), encoding="utf-8")
            print("wrote", name)


if __name__ == "__main__":
    main()
