"""
Build renderer template KabarNikah dari ZIP sumber (sources/Txx).
- Menyalin aset ke app/public/tpl/<id>/assets (tanpa config.js/api.php/branding penyedia lama).
- Membuat app/kabarnikah/templates/<id>/view.html dengan penanda <!--KN:HEAD--> <!--KN:BODY--> <!--KN:PRE--> <!--KN:POST-->.
- Menerapkan patch terarah pada JS/CSS asli (setiap patch WAJIB cocok; bila tidak, build gagal).
Jalankan: python3 -I tools/build_templates.py <root-proyek>
"""
import os, re, shutil, sys, json

ROOT = sys.argv[1] if len(sys.argv) > 1 else '/home/user/kabarnikah'
SRC = os.path.join(ROOT, 'sources')
PUB = os.path.join(ROOT, 'app/public/tpl')
PRIV = os.path.join(ROOT, 'app/kabarnikah/templates')
LOG = []

def patch(text, old, new, label, count=1, regex=False):
    if regex:
        n = len(re.findall(old, text, flags=re.S))
        if n < 1:
            raise SystemExit(f'PATCH GAGAL [{label}]: pola tidak ditemukan')
        text = re.sub(old, new, text, count=0 if count == 0 else count, flags=re.S)
    else:
        n = text.count(old)
        if n < 1:
            raise SystemExit(f'PATCH GAGAL [{label}]: teks tidak ditemukan')
        text = text.replace(old, new) if count == 0 else text.replace(old, new, count)
    LOG.append(label)
    return text


def remove_balanced(html, start_regex, label, tag='div', required=True):
    """Hapus elemen lengkap (seimbang) yang tag pembukanya cocok dengan start_regex."""
    out, pos, n = [], 0, 0
    for m in re.finditer(start_regex, html):
        if m.start() < pos:
            continue
        depth, i = 0, m.start()
        tok = re.compile(r'<(/?)' + tag + r'\b[^>]*>', re.S)
        for t in tok.finditer(html, m.start()):
            depth += -1 if t.group(1) else 1
            if depth == 0:
                out.append(html[pos:m.start()]); pos = t.end(); n += 1
                break
    out.append(html[pos:])
    if required and n == 0:
        raise SystemExit(f'PATCH GAGAL [{label}]: elemen tidak ditemukan')
    LOG.append(f'{label} ({n})')
    return ''.join(out)

def copy_assets(tid, src_dir, skip=()):
    dst = os.path.join(PUB, tid, 'assets')
    if os.path.isdir(dst):
        shutil.rmtree(dst)
    shutil.copytree(os.path.join(src_dir, 'assets'), dst, ignore=shutil.ignore_patterns(*skip))
    return dst

def rd(p):
    return open(p, encoding='utf-8').read()

def wr(p, s):
    os.makedirs(os.path.dirname(p), exist_ok=True)
    open(p, 'w', encoding='utf-8').write(s)

def markers(html, first_script, label):
    m = re.search(r'<meta charset="utf-8"\s*/?>', html, flags=re.I)
    if not m:
        raise SystemExit(f'PATCH GAGAL [{label}:head]: meta charset tidak ditemukan')
    html = html[:m.end()] + '<!--KN:HEAD-->' + html[m.end():]
    LOG.append(label + ':head')
    html = re.sub(r'<title>.*?</title>', '', html, count=1, flags=re.S)
    html = re.sub(r'<meta (?:name="description" content="[^"]*"|content="[^"]*" name="description")\s*/?>', '', html, count=1)
    html = re.sub(r'<link (?:rel="icon"[^>]*|[^>]*rel="icon"[^>]*)>', '', html)
    html = patch(html, '<body>', '<body><!--KN:BODY-->', label + ':body') if '<body>' in html else patch(html, '<body ', '<body data-kn ', label + ':body2')
    html = patch(html, first_script, '<!--KN:PRE-->' + first_script, label + ':pre')
    html = patch(html, '</body>', '<!--KN:POST--></body>', label + ':post')
    return html

KN_FOOTER = ('<p class="kn-made">dibuat dengan</p><p class="kn-brand"><a class="kn-brand-link" href="#" rel="noopener">'
             '<img class="kn-brand-logo" src="../../assets/brand/kabarnikah-logo.webp" alt="KabarNikah" width="64" height="61">'
             '<b>Kabar<span>Nikah</span></b></a></p>')

# =========================================================== T01 Glass Wonder
def build_t01():
    tid, src = 'glass-wonder', os.path.join(SRC, 'T01_glass_wonder')
    dst = copy_assets(tid, src, skip=('owl-icon.2da6fa4.png', 'ig.9f6737c.png', 'loading-owl_b72fd3a-d2e81359.exact.gif', 'favicon-32.png', 'favicon.svg'))
    for f in ('roboto-300.ttf', 'roboto-900.ttf'):
        shutil.copy(os.path.join(SRC, 'T02_burgundy_vintage/assets', f), os.path.join(dst, f))
    css = rd(os.path.join(dst, 'original.css'))
    css, n = re.subn(r'url\((["\']?)https://our-wedding\.link/_nuxt/fonts/Roboto-(?:Light|Regular)[^)"\']*\1\)', 'url(roboto-300.ttf)', css)
    css, n2 = re.subn(r'url\((["\']?)https://our-wedding\.link/_nuxt/fonts/Roboto-(?:Black|Bold|Medium)[^)"\']*\1\)', 'url(roboto-900.ttf)', css)
    LOG.append(f't01:css fonts lokal ({n}+{n2})')
    css = neutralize_remote_css(css, 't01:original.css')
    wr(os.path.join(dst, 'original.css'), css)
    # --- lax.min.js: ganti Function("return "+ekspresi) (butuh unsafe-eval) dengan evaluator aritmetika aman
    lax = rd(os.path.join(dst, 'lax.min.js'))
    lax = patch(lax, 'return Function("return ".concat(e.replace(', 'return __knArith((e.replace(', 't01:lax tanpa eval')
    lax = patch(lax, '.replace(/index/g,n)))()}', '.replace(/index/g,n)))}', 't01:lax tanpa eval (tutup)')
    lax = ("function __knArith(s){s=String(s).replace(/\\s+/g,'');var i=0;function num(){var m=/^-?\\d+(?:\\.\\d+)?(?:e[+-]?\\d+)?/i.exec(s.slice(i));if(!m)return 0;i+=m[0].length;return parseFloat(m[0])}"
           "function factor(){if(s[i]==='('){i++;var v=expr();i++;return v}if(s[i]==='-'&&s[i+1]==='('){i++;return -factor()}return num()}"
           "function term(){var v=factor();while(s[i]==='*'||s[i]==='/'||s[i]==='%'){var op=s[i++],r=factor();v=op==='*'?v*r:op==='/'?v/r:v%r}return v}"
           "function expr(){var v=term();while(s[i]==='+'||s[i]==='-'){var op=s[i++],r=term();v=op==='+'?v+r:v-r}return v}return expr()}\n") + lax
    wr(os.path.join(dst, 'lax.min.js'), lax)
    # --- HTML desktop
    html = rd(os.path.join(src, 'index.html'))
    html = patch(html, '<script src="config.js"></script>', '', 't01:hapus config.js')
    html = patch(html, r'<a href="#" target="_self" class="trigger-custom-design".*?</a>', '', 't01:hapus kuas edit lokal', regex=True)
    html = patch(html, r'data-img="https://cdn-uploads\.owlink\.id/[^"]*"', 'data-img=""', 't01:hapus data-img penyedia', count=0, regex=True)
    html = remove_balanced(html, r'<div class="item-wish"', 't01:hapus ucapan contoh (desktop)')
    html = re.sub(r'https://www\.instagram\.com/ourwedding\.link/[^"]*', '#', html)
    html = patch(html, r'src="https://www\.youtube\.com/embed/LHXNqbstdq0[^"]*"', 'src="about:blank"', 't01:video contoh tidak dimuat', count=0, regex=True)
    html = markers(html, '<script src="assets/jquery.min.js"></script>', 't01')
    wr(os.path.join(PRIV, tid, 'view.html'), html)
    # --- markup ponsel (string JS)
    mt = rd(os.path.join(dst, 'mobile-template.js'))
    m = re.match(r'(window\.WEDDING_MOBILE_MARKUP\s*=\s*)("(?:[^"\\]|\\.)*")(;?\s*)$', mt, re.S)
    if not m:
        raise SystemExit('t01: format mobile-template.js tidak dikenali')
    markup = json.loads(m.group(2))
    markup = patch(markup, r'<section[^>]*id="footer".*?</section>', '<section id="footer" data-v-28263841=""><div class="container"><div class="row"><div class="col-12 kn-footer">' + KN_FOOTER + '</div></div></div></section>', 't01:footer ponsel → KabarNikah', regex=True)
    markup = re.sub(r'data-img="https://cdn-uploads\.owlink\.id/[^"]*"', 'data-img=""', markup)
    markup = remove_balanced(markup, r'<div[^>]*class="item-wish"', 't01:hapus ucapan contoh (ponsel)', required=False)
    markup = re.sub(r'https://www\.instagram\.com/ourwedding\.link/[^"]*', '#', markup)
    markup = re.sub(r'src="https://www\.youtube\.com/embed/LHXNqbstdq0[^"]*"', 'src="about:blank"', markup)
    wr(os.path.join(dst, 'mobile-template.js'), m.group(1) + json.dumps(markup, ensure_ascii=False) + ';\n')
    # --- entry.js: layar muat owl → layar muat KabarNikah
    entry = rd(os.path.join(dst, 'entry.js'))
    entry = patch(entry, "const loader=document.createElement('div');loader.id='gw-loader';", "const loader=document.createElement('div');loader.id='gw-loader-legacy';", 't01:entry id')
    entry = patch(entry, "loader.innerHTML='<div class=\"form-layout-center container\" data-v-47629b56><div class=\"row align-items-center\" data-v-47629b56><div class=\"col-12\" data-v-47629b56><p data-v-47629b56><img src=\"assets/loading-owl_b72fd3a-d2e81359.exact.gif\" alt=\"\" style=\"width:16%\" data-v-47629b56></p></div></div></div>';document.body.append(loader);", "/* layar muat diganti KNLoader */", 't01:entry hapus gif owl')
    entry = patch(entry, "loader.style.opacity=0;setTimeout(()=>loader.remove(),250);", "if(window.KNLoader)KNLoader.done();", 't01:entry KNLoader.done')
    wr(os.path.join(dst, 'entry.js'), entry)
    # --- app.js
    app = rd(os.path.join(dst, 'app.js'))
    app = patch(app, "let custom = params.has('edit') ? read('gw-custom', {}) : {};", "let custom = {};", 't01:app matikan edit lokal')
    app = patch(app, "  function applyConfig() {", "  function applyConfig() {\n    if (window.KNAdapter) { window.KNAdapter.apply(window.KN.payload, { initial: true }); return; }\n    return legacyApplyConfig();\n  }\n  function legacyApplyConfig() {", 't01:app applyConfig → KNAdapter')
    app = patch(app, "    const date=new Date(config.date), end=new Date(date.getTime()+3*3600000);const fmt=d=>d.toISOString().replace(/[-:]/g,'').replace(/\\.\\d{3}/,'');",
                "    const kev=(window.KN&&KN.data.events||[])[(KN.data.settings||{}).eventForCountdown||0]||(KN.data.events||[])[0];if(window.KN&&KN.downloadIcs(kev)){toast('Jadwal acara telah diunduh.');return;}toast('Tanggal acara belum diisi.');return;\n    const date=new Date(config.date), end=new Date(date.getTime()+3*3600000);const fmt=d=>d.toISOString().replace(/[-:]/g,'').replace(/\\.\\d{3}/,'');", 't01:app kalender dari data acara')
    app = patch(app, """  async function api(action,body) {
    if(!config.api)return null;""", """  async function api(action,body) {
    if(window.KN){
      if(action==='wishes'){const r=await KN.api.wishes();return {wishes:(r.wishes||[]).map(w=>({name:w.name,city:w.origin||'',message:w.message,createdAt:w.created_at}))};}
      if(action==='wish'){const r=await KN.api.wish({name:body.name,origin:body.city,message:body.message});if(r&&r.demo){toast(r.message);return {demo:true};}if(r&&r.pending){toast('Terima kasih. Ucapan akan tampil setelah ditinjau pasangan.');return {pending:true};}return r;}
      if(action==='rsvp'){const r=await KN.api.rsvp({name:body.name,phone:body.phone||'',origin:body.city||'',position:body.position||'',response:body.response,pax:Number(body.guests||1)});if(r&&r.demo){return {demo:true,message:r.message};}return r;}
    }
    if(!config.api)return null;""", 't01:app api → KN.api')
    app = patch(app, "try{const result=await api('wish',wish);if(!result){", "try{const result=await api('wish',wish);if(result&&result.pending){wishForm.reset();return;}if(result&&result.demo){renderWish(wish);wishForm.reset();return;}if(!result){", 't01:app ucapan demo/moderasi')
    app = patch(app, "  if(config.api)api('wishes')", "  if(window.KN)KN.onUpdate(()=>{});\n  if(window.KN||config.api)api('wishes')", 't01:app muat ucapan dari API')
    app = patch(app, "  let wishes=read('gw-wishes',[]);", "  let wishes=window.KN?[]:read('gw-wishes',[]);", 't01:app tanpa ucapan lokal')
    app = patch(app, "  const brush=$one('.trigger-custom-design');", "  const brush=null;", 't01:app tanpa kuas')
    app = patch(app, "  window.GW={config,toast,api,read,write};", "  window.GW={config,toast,api,read,write,setupCarousels,countdown,renderWish};", 't01:app ekspor fungsi')
    wr(os.path.join(dst, 'app.js'), app)
    # --- modals.js
    mod = rd(os.path.join(dst, 'modals.js'))
    mod = patch(mod, "const saved=read('gw-rsvp',null);", "const saved=window.KN&&KN.mode!=='published'?null:read('gw-rsvp',null);", 't01:modal rsvp tanpa data lama di demo')
    mod = patch(mod, "        const result=await api('rsvp',data);\n        if(!write('gw-rsvp',data)&&!result)throw new Error('Penyimpanan perangkat tidak tersedia.');\n        modal.close();toast(result?'Terima kasih, konfirmasi kehadiran telah tersimpan.':'Konfirmasi tersimpan di perangkat ini (mode pratinjau).');",
                "        const result=await api('rsvp',data);\n        if(result&&!result.demo)write('gw-rsvp',data);\n        modal.close();toast(result&&result.demo?result.message:'Terima kasih, konfirmasi kehadiran telah tersimpan.');", 't01:modal rsvp ke API')
    mod = patch(mod, "data.guests=1;", "data.guests=form.elements.guests?Number(form.elements.guests.value||1):1;", 't01:modal jumlah tamu')
    mod = patch(mod, """        const cards=[...modal.querySelectorAll('.km-daftar .km-kartu')];
        const first=cards[0];
        if(first){
          first.querySelector('.km-bank').textContent=gift.bank||'BCA';
          first.querySelector('.km-norek').textContent=gift.account||'';
          first.querySelector('.km-atas-nama').textContent='a/n '+(gift.name||'');
        }
        if(gift.account!=='0660580697')cards.slice(1).forEach(card=>card.remove());""", """        if(window.KNAdapter)KNAdapter.fillGift(modal);""", 't01:modal hadiah semua rekening')
    mod = patch(mod, "        const address=modal.querySelector('.km-alamat__teks');if(address&&gift.address)address.textContent=gift.address;", "        const address=modal.querySelector('.km-alamat__teks');if(window.KNAdapter)KNAdapter.fillAddress(modal);", 't01:modal alamat hadiah')
    wr(os.path.join(dst, 'modals.js'), mod)
    # --- templates.js: hapus rekening/QR contoh penyedia (diisi adaptor)
    tj = rd(os.path.join(dst, 'templates.js'))
    tj = tj.replace('0660580697', '').replace('Eka Syafitry Dewi', '').replace('a/n Our Wedding', 'a/n').replace('12132131231', '')
    LOG.append('t01:templates rekening contoh penyedia dihapus')
    wr(os.path.join(dst, 'templates.js'), tj)

# =========================================================== T02 Burgundy Vintage (Hitam)
def build_t02():
    tid, src = 'burgundy-vintage', os.path.join(SRC, 'T02_burgundy_vintage')
    dst = copy_assets(tid, src, skip=('footer-owl.png', 'footer-instagram.png'))
    html = rd(os.path.join(src, 'index.html'))
    html = patch(html, '<script src="config.js"></script>', '', 't02:hapus config.js')
    html = remove_balanced(html, r'<div class="custom-design-wrapper"', 't02:hapus panel demo penyedia + tautan login')
    html = remove_balanced(html, r'<div aria-label="Choose design colour" class="pw"', 't02:hapus swatch warna non-fungsional')
    html = patch(html, r'<footer class="dl-brand".*?</footer>', '<footer class="dl-brand kn-footer kn-dark" style="background-color: rgb(23, 21, 21); color: rgb(240, 234, 234); padding: 28px 0 34px;">' + KN_FOOTER + '</footer>', 't02:footer → KabarNikah', regex=True)
    # Sampul ponsel memuat gambar dari CDN penyedia lama: arahkan ke aset lokal blok yang sama
    local = {}
    for m in re.finditer(r'data-block-id="(image-[^"]+)"[^>]*>\s*(?:<[^>]+>\s*)*?<img[^>]*src="(assets/[^"]+)"', html):
        local.setdefault(m.group(1), m.group(2))
    n = 0
    def fix(m):
        nonlocal n
        blk = m.group(1); seg = m.group(0)
        if blk in local:
            n += 1
            return re.sub(r'src="https://owlink\.id/[^"]*"', 'src="' + local[blk] + '"', seg)
        return seg
    html = re.sub(r'data-block-id="(image-[^"]+)".{0,900}?src="https://owlink\.id/[^"]*"', fix, html, flags=re.S)
    LOG.append(f't02:gambar sampul ponsel dari CDN penyedia → lokal ({n})')
    html = patch(html, 'url("https://owlink.id/_ipx/w_1080,f_webp,q_75/https://cdn-uploads.owlink.id/7a1ed270-d8d5-11f1-b610-6fa5a6b0bfa3.jpg")', 'url("assets/asset-3b319e07c607.jpg")', 't02:tekstur velvet sampul ponsel → lokal')
    if 'owlink.id' in re.sub(r'<style.*?</style>', '', html, flags=re.S):
        rest = re.findall(r'https://owlink\.id[^"\')]*', html)
        LOG.append(f't02:PERINGATAN sisa owlink: {len(rest)}')
    # Ucapan contoh: sisakan satu slide sebagai prototipe (dikosongkan adaptor)
    html = remove_balanced(html, r'<div class="ws-slide">(?=(?:(?!<div class="ws-slide">).)*?Our Wedding Link)', 't02:hapus ucapan contoh penyedia', required=False)
    html = patch(html, r'(<audio[^>]*?)\ssrc="assets/background-music\.mp3"', r'\1 data-kn-src=""', 't02:audio contoh tidak dimuat', regex=True)
    html = markers(html, '<script src="assets/jquery.min.js"></script>', 't02')
    wr(os.path.join(PRIV, tid, 'view.html'), html)
    app = rd(os.path.join(dst, 'app.js'))
    app = patch(app, """  async function submit(action,data){
    if(!cfg.api)return false;""", """  async function submit(action,data){
    if(window.KN){
      if(action==='wish'){const r=await KN.api.wish({name:data.name,origin:data.location||'',message:data.message});if(r&&r.demo){notice(r.message);return 'demo';}if(r&&r.pending){notice('Thank you. Your wish will appear after review.');return 'pending';}return true;}
      if(action==='rsvp'){const r=await KN.api.rsvp({name:data.name,phone:data.phone,origin:data.location,position:(data.rsvp_option[0]||{}).value||'',response:data.response,pax:data.participant});return r&&r.demo?'demo':true;}
    }
    if(!cfg.api)return false;""", 't02:app kirim → KN.api')
    app = patch(app, "      const server=await submit('rsvp',data);if(!save('burgundy-rsvp',data)&&!server)throw new Error('Device storage is unavailable.');\n      formResult(rsvp,server?'Successfully send a rsvp':'Confirmation saved on this device.');",
                "      const server=await submit('rsvp',data);\n      formResult(rsvp,server==='demo'?KN.api.note:'Successfully send a rsvp');", 't02:app rsvp hasil')
    app = patch(app, "  read('burgundy-wishes',[]).forEach(addWish);", "  window.KNT02={addWish,wishes,gallery};", 't02:app tanpa ucapan lokal')
    app = patch(app, "      const server=await submit('wish',data),saved=read('burgundy-wishes',[]);saved.push({...data,createdAt:Date.now()});\n      if(!save('burgundy-wishes',saved.slice(-100))&&!server)throw new Error('Device storage is unavailable.');\n      addWish(data);fields.forEach(field=>field.value='');notice(server?'Successfully send a wish':'Wish saved on this device.');",
                "      const server=await submit('wish',data);\n      if(server!=='pending')addWish(data);fields.forEach(field=>field.value='');if(server===true)notice('Successfully send a wish');", 't02:app ucapan hasil')
    app = patch(app, "document.title=bindings.couple+' — Burgundy Vintage Hitam';", "", 't02:app judul dari server')
    app = patch(app, "const bindings={...cfg,couple:(cfg.bride||'Anindya')+' & '+(cfg.groom||'Rafael')};", "const bindings={...cfg,couple:cfg.couple||((cfg.bride||'')+' & '+(cfg.groom||''))};", 't02:app nama pasangan')
    app = patch(app, "mobileCover.querySelectorAll('p').forEach(el=>{if(el.textContent.trim()==='Anindya & Rafael')el.textContent=bindings.couple;});", "if(window.KNAdapter)KNAdapter.bindCover(mobileCover);", 't02:app sampul ponsel diikat adaptor')
    app = patch(app, "mobileCover.querySelector('a').addEventListener('click',event=>{\n    event.preventDefault();", "mobileCover.querySelector('a').addEventListener('click',event=>{\n    event.preventDefault();window.dispatchEvent(new Event('kn:opened'));", 't02:app event dibuka')
    wr(os.path.join(dst, 'app.js'), app)

def neutralize_remote_css(css, label):
    css, n1 = re.subn(r'url\((["\']?)https://our-wedding\.link/_nuxt/fonts/[^)]*\)\s*format\([^)]*\)\s*,?\s*', '', css)
    css, n2 = re.subn(r'url\((["\']?)https://our-wedding\.link/_nuxt/fonts/[^)]*\)', 'local("kn-none")', css)
    css, n3 = re.subn(r'url\((["\']?)https://(?:our-wedding\.link|cdn-uploads\.owlink\.id|owlink\.id)[^)]*\)', 'none', css)
    LOG.append(f'{label}: url penyedia lama dinetralkan ({n1}+{n2}+{n3})')
    return css

# =========================================================== T03 Isometric Castle
def build_t03():
    tid, src = 'isometric-castle', os.path.join(SRC, 'T03_isometric_castle')
    dst = copy_assets(tid, src)
    for f in ('fonts.css', 'source-ui.css'):
        wr(os.path.join(dst, f), neutralize_remote_css(rd(os.path.join(dst, f)), 't03:' + f))
    # Font Google (Cinzel, Space Grotesk, dll.) diunduh sekali ke folder lokal agar tanpa CDN pihak ketiga
    css = rd(os.path.join(dst, 'source-ui.css'))
    css, nimp = re.subn(r'@import url\(https://fonts\.googleapis\.com[^)]*\);?', '', css)
    cache = os.path.join(ROOT, 'tools/.fontcache'); os.makedirs(cache, exist_ok=True)
    def dl(m):
        u = m.group(1); name = 'gf-' + re.sub(r'[^a-z0-9.]+', '-', u.split('/s/')[1].lower())
        cp = os.path.join(cache, name)
        if not os.path.exists(cp):
            import urllib.request
            req = urllib.request.Request(u, headers={'User-Agent': 'Mozilla/5.0'})
            open(cp, 'wb').write(urllib.request.urlopen(req, timeout=30).read())
        shutil.copy(cp, os.path.join(dst, 'fonts', name))
        return 'url(fonts/' + name + ')'
    css, nf = re.subn(r'url\((https://fonts\.gstatic\.com/[^)]+)\)', dl, css)
    shutil.copy(os.path.join(SRC, 'T01_glass_wonder/assets/montserrat-c073e38.ttf'), os.path.join(dst, 'fonts', 'montserrat.ttf'))
    css, nm = re.subn(r'url\((["\']?)https://api\.owlink\.id/uploads/fonts/montserrat\.ttf\1\)', 'url(fonts/montserrat.ttf)', css)
    LOG.append(f't03:Montserrat dari API penyedia → lokal ({nm})')
    wr(os.path.join(dst, 'source-ui.css'), css)
    LOG.append(f't03:font Google dihosting lokal ({nf}), @import eksternal dihapus ({nimp})')
    html = rd(os.path.join(src, 'index.html'))
    html = patch(html, '<script src="config.js"></script>', '', 't03:hapus config.js')
    html = markers(html, '<script src="assets/vue.min.js"></script>', 't03')
    wr(os.path.join(PRIV, tid, 'view.html'), html)
    pf = rd(os.path.join(dst, 'platform.js'))
    pf = patch(pf, "  const to=new URLSearchParams(location.search).get('to');", "  const to=window.KN?KN.guestName():new URLSearchParams(location.search).get('to');", 't03:platform tamu dari KN')
    pf = patch(pf, "  const state=Vue.observable({", "  const state=window.CASTLE_STATE=Vue.observable({", 't03:platform ekspor state')
    pf = patch(pf, "  async function api(action,body){\n    if(!config.api)return null;", """  async function api(action,body){
    if(window.KN){
      if(action==='rsvp'){const r=await KN.api.rsvp({name:body.name,phone:body.phone||'',origin:body.location||'',position:((body.rsvp_option||[])[0]||{}).value||'',response:body.response,pax:Number(body.participant||1)});if(r&&r.demo)throw new Error(KN.api.note);return r;}
      if(action==='wish'){const r=await KN.api.wish({name:body.name,origin:'',message:body.message});if(r&&r.demo)throw new Error(KN.api.note);return r;}
    }
    if(!config.api)return null;""", 't03:platform api → KN.api')
    pf = patch(pf, "couple_child_number:Vue.prototype.$t('couple.'+(female?'daughter_of':'son_of'),{var:invitation[p+'child_number']||''}),couple_father_full_name:(invitation[p+'father_full_name']||'').replace(/^Bapak\\s+/,'Mr. '),couple_mother_full_name:(invitation[p+'mother_full_name']||'').replace(/^Ibu\\s+/,'Mrs. ')",
               "couple_child_number:invitation[p+'child_number']||'',couple_father_full_name:invitation[p+'father_full_name']||'',couple_mother_full_name:invitation[p+'mother_full_name']||''", 't03:platform teks profil apa adanya')
    pf = patch(pf, "          if(!save('castle-rsvp',data)&&!result)throw new Error('Browser storage is not available.');", "", 't03:platform rsvp tanpa simpan lokal')
    pf = patch(pf, "const result=await api('wish',data),items=read('castle-wishes',[]);items.push({...data,createdAt:Date.now()});\n          if(!save('castle-wishes',items.slice(-100))&&!result)throw new Error('Browser storage is not available.');", "const result=await api('wish',data);", 't03:platform ucapan tanpa simpan lokal')
    pf = patch(pf, "copyStringToClipboard(text){this.gift.copy_text=text;this.$nextTick(()=>{const input=document.querySelector('#copy-input');if(input){input.select();input.setSelectionRange(0,99999);document.execCommand('copy');}alert('Copied: '+text);});}",
               "copyStringToClipboard(text){this.gift.copy_text=text;const done=()=>toast('Copied: '+text);if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(text).then(done,()=>toast('Copy: '+text));return;}this.$nextTick(()=>{const input=document.querySelector('#copy-input');if(input){input.select();input.setSelectionRange(0,99999);try{document.execCommand('copy');done();}catch(e){toast('Copy: '+text);}}});}", 't03:platform salin tanpa alert')
    wr(os.path.join(dst, 'platform.js'), pf)
    # Ikon panah galeri mode scroll-left masih menunjuk CDN penyedia (tidak ada salinan lokal): ganti SVG data-URI.
    rv = rd(os.path.join(dst, 'reference-views.js'))
    arrow = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath d='M18 10l14 14-14 14' fill='none' stroke='%23254773' stroke-width='5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"
    rv = patch(rv, 'https://cdn-uploads.owlink.id/contenful/Copy_of_Heading__18_.png', arrow, 't03:ikon panah galeri lokal')
    wr(os.path.join(dst, 'reference-views.js'), rv)
    dd = rd(os.path.join(dst, 'default-data.js'))
    dd = dd.replace('0660580697', '').replace('Eka Syafitry Dewi', '').replace('12132131231', '').replace('"Our Wedding "', '""')
    LOG.append('t03:rekening contoh penyedia dihapus dari default-data')
    wr(os.path.join(dst, 'default-data.js'), dd)

# =========================================================== T04 Istana Kertas
def build_t04():
    tid, src = 'istana-kertas', os.path.join(SRC, 'T04_istana_kertas')
    dst = copy_assets(tid, src)
    html = rd(os.path.join(src, 'index.html'))
    html = patch(html, '<script src="config.js"></script>', '', 't04:hapus config.js')
    html = re.sub(r'0660580697|12132131231', '', html)
    html = html.replace('a/n Eka Syafitry Dewi', 'a/n').replace('a/n Our Wedding ', 'a/n')
    LOG.append('t04:rekening contoh penyedia dihapus')
    html = patch(html, r'(<audio[^>]*?)\ssrc="assets/background-music\.mp3[^"]*"', r'\1 data-kn-src=""', 't04:audio contoh tidak dimuat', regex=True)
    first = re.search(r'<script[^>]*src="assets/[^"]+"[^>]*></script>', html).group(0)
    html = markers(html, first, 't04')
    wr(os.path.join(PRIV, tid, 'view.html'), html)
    app = rd(os.path.join(dst, 'app.js'))
    app = patch(app, "  async function save(type,data){\n    if(cfg.api){", """  async function save(type,data){
    if(window.KN){
      if(type==='rsvp'){const r=await KN.api.rsvp({name:data.name||'',phone:data.phone||'',origin:data.location||'',position:data.position||'',response:data.response,pax:Number(data.participant||1)});return r&&r.demo?{ok:true,demo:true}:{ok:true};}
      if(type==='wish'){const r=await KN.api.wish({name:data.name||'',origin:data.location||'',message:data.message||''});return r&&r.demo?{ok:true,demo:true}:(r&&r.pending?{ok:true,pending:true}:{ok:true});}
    }
    if(cfg.api){""", 't04:app simpan → KN.api')
    app = patch(app, "try{await save('rsvp',Object.fromEntries(new FormData(form)));result(form,cfg.api?'Confirmation saved. Thank you!':'Confirmation saved on this device.');}",
                "try{const r=await save('rsvp',Object.fromEntries(new FormData(form)));result(form,r&&r.demo?KN.api.note:'Confirmation saved. Thank you!');}", 't04:app hasil rsvp')
    app = patch(app, "try{await save('wish',data);appendWish(root,data);fields.forEach(n=>n.value='');notice(cfg.api?'Thank you for your wishes.':'Wish saved on this device.');resize()}",
                "try{const r=await save('wish',data);if(!(r&&r.pending))appendWish(root,data);fields.forEach(n=>n.value='');notice(r&&r.demo?KN.api.note:(r&&r.pending?'Thank you. Your wish will appear after review.':'Thank you for your wishes.'));resize()}", 't04:app hasil ucapan')
    app = patch(app, "    if(cfg.api){fetch(cfg.api+'?action=wishes').then(r=>r.json()).then(data=>{if(data.ok&&activePanel==='wishes'){(data.wishes||[]).slice().reverse().forEach(item=>appendWish(root,item));resize();}}).catch(()=>{});}else{try{JSON.parse(localStorage.getItem('istana-kertas-wish')||'[]').slice().reverse().forEach(data=>appendWish(root,data))}catch{}}",
                "    if(window.KN){KN.api.wishes().then(data=>{if(activePanel==='wishes'){if(window.KNAdapter)KNAdapter.clearWishes(root);(data.wishes||[]).slice().reverse().forEach(item=>appendWish(root,{name:item.name,location:item.origin||'',message:item.message}));resize();}}).catch(()=>{});}", 't04:app muat ucapan dari API')
    app = patch(app, "const card=example.cloneNode(true);", "const card=example.cloneNode(true);card.classList.remove('kn-proto');card.style.display='';", 't04:app klon ucapan terlihat')
    app = patch(app, "  let guest = new URLSearchParams(location.search).get('to') || '';", "  let guest = window.KN ? KN.guestName() : (new URLSearchParams(location.search).get('to') || '');", 't04:app tamu dari KN')
    app = patch(app, "async e=>{const copy=e.target.closest('[data-copy]');if(copy){try{await navigator.clipboard.writeText(copy.dataset.copy.trim());notice('Copied.')}catch{notice('Copy is unavailable in this browser.')}return;}",
                "async e=>{const copy=e.target.closest('[data-copy]');if(copy){(window.KNCopy?KNCopy(copy.dataset.copy.trim()):navigator.clipboard.writeText(copy.dataset.copy.trim())).then(()=>notice('Copied.'),()=>notice('Copy: '+copy.dataset.copy.trim()));return;}", 't04:app salin dengan cadangan')
    wr(os.path.join(dst, 'app.js'), app)

if __name__ == '__main__':
    which = sys.argv[2:] or ['t01', 't02', 't03', 't04']
    for w in which:
        globals()['build_' + w]()
    print('\n'.join(LOG))
