#!/usr/bin/env python3
"""
Mise a jour des pages de webdelphes.com par l'API REST.

DEUX PRINCIPES, tires de ce que le site contient reellement :

1. On ne remplace JAMAIS le contenu d'une page par un fichier local. Les copies
   du depot ont diverge : la page d'accueil en ligne porte une image et un appel
   a l'action commente que le depot ignore. On lit le contenu en ligne, on y
   insere ce qui manque, on le reecrit.

2. Les insertions sont IDEMPOTENTES : chacune est bornee par un commentaire
   marqueur. Relancer le script remplace le bloc au lieu de l'empiler.

Les liens de l'index pointent en `?page_id=N`, comme le fait deja le site :
c'est la seule forme qui fonctionne tant que les pages sont en brouillon, et
WordPress redirige vers le permalien propre une fois publiees.

Usage :
    python3 wp_publish.py --dry-run     n'ecrit rien, montre ce qui changerait
    python3 wp_publish.py --apply       ecrit
    python3 wp_publish.py --revert --apply         retire tout ce qui a ete pose
    python3 wp_publish.py --revert-index --apply   retire la seule colonne d'index
"""
import json, os, re, sys, base64, urllib.request, urllib.error

CREDS = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.wpcreds')
conf = {}
for line in open(CREDS, encoding='utf-8'):
    if '=' in line:
        k, v = line.strip().split('=', 1)
        conf[k] = v.strip().strip("'")

SITE = conf['WP_SITE']
AUTH = base64.b64encode(f"{conf['WP_USER']}:{conf['WP_PASS']}".encode()).decode()

DOC_PARENT = {'fr': 1214, 'en': 1215}
HOME = {'fr': 1122, 'en': 1124}

# Ordre d'affichage dans l'index. Les slugs sont ceux du site, pas les miens :
# la premiere version de l'index inventait `installation-linux-rhel-apache`,
# qui n'existe pas.
ORDER = [
    ('DEMARRER',   ['installation-paquet-zip']),
    ('WINDOWS',    ['installation-windows-apache', 'installation-windows-iis']),
    ('LINUX',      ['installation-rhel-apache', 'installation-rhel-nginx',
                    'installation-ubuntu-apache', 'installation-ubuntu-nginx']),
    ('UTILISER',   ['guide-utilisateur-oracle', 'oracle-user-guide']),
    ('ADMINISTRER', ['administration-generale', 'general-administration',
                     'administration-module-oracle', 'oracle-module-administration']),
]
LABEL_EN = {'DEMARRER': 'GETTING STARTED', 'WINDOWS': 'WINDOWS', 'LINUX': 'LINUX',
            'UTILISER': 'USING', 'ADMINISTRER': 'ADMINISTERING'}

# Les marqueurs sont des ATTRIBUTS, pas des commentaires : wpautop enveloppe
# un commentaire isole dans un <p>, qui devient alors un element flex parasite
# entre l'index et le contenu. C'est ce qui faisait chevaucher les deux.
MARK_NAV_A, MARK_NAV_B = '<!-- WD-INDEX-DEBUT -->', '<!-- WD-INDEX-FIN -->'
ATTR_NAV  = 'data-wd-index="1"'
ATTR_COLS = 'data-wd-cols="1"'
ATTR_COL  = 'data-wd-col="1"'
MARK_ANN_A, MARK_ANN_B = '<!-- WD-ANNONCE-DEBUT -->', '<!-- WD-ANNONCE-FIN -->'


def api(path, method='GET', payload=None):
    url = SITE + '/wp-json/wp/v2/' + path
    data = json.dumps(payload).encode() if payload is not None else None
    req = urllib.request.Request(url, data=data, method=method)
    req.add_header('Authorization', 'Basic ' + AUTH)
    if data:
        req.add_header('Content-Type', 'application/json')
    with urllib.request.urlopen(req, timeout=60) as r:
        return json.loads(r.read().decode())


def fetch_pages():
    out, page = [], 1
    while True:
        batch = api(f'pages?per_page=100&status=any&context=edit&page={page}')
        out += batch
        if len(batch) < 100:
            return out
        page += 1


S_A = 'display:block;padding:.3rem .5rem;border-radius:4px;text-decoration:none;color:#334155;font-size:.9rem'
S_A_ON = 'display:block;padding:.3rem .5rem;border-radius:4px;text-decoration:none;color:#fff;background:#2563eb;font-size:.9rem;font-weight:600'
S_H = 'margin:1rem 0 .35rem;font-size:.72rem;letter-spacing:.06em;text-transform:uppercase;color:#94a3b8;font-weight:700'


def build_nav(pages_by_slug, lang, current_id):
    """
    L'index complet d'une langue, l'entree courante mise en evidence.

    Rendu sur UNE SEULE LIGNE, sans aucun saut : wpautop ne coupe qu'aux lignes
    vides et transforme les sauts restants en <br>. Une nav multiligne se
    retrouvait donc avec un <p> par lien, dont le dernier n'etait jamais ferme
    avant </nav> — d'ou le chevauchement.
    """
    title = 'Documentation'
    out = ['<nav ' + ATTR_NAV + ' style="flex:1 1 220px;max-width:260px;min-width:200px;align-self:flex-start;'
           'position:sticky;top:1rem;border:1px solid #e2e8f0;border-radius:8px;'
           'padding:.8rem 1rem;background:#f8fafc">',
           '<p style="margin:0;font-size:.8rem;letter-spacing:.05em;text-transform:uppercase;'
           'color:#2563eb;font-weight:700">%s</p>' % title]
    first = True
    for group, slugs in ORDER:
        entries = [pages_by_slug[s] for s in slugs if s in pages_by_slug]
        if not entries:
            continue
        lab = group if lang == 'fr' else LABEL_EN[group]
        out.append('<p style="%s%s">%s</p>' % (S_H, ';margin-top:.7rem' if first else '', lab))
        first = False
        for p in entries:
            on = (p['id'] == current_id)
            # Chaque lien est enveloppe dans un <p> : wpautop ajoute des
            # sauts autour de CHAQUE balise de bloc, si bien qu'un <a> nu
            # entre deux blocs se retrouve enveloppe par lui — et le dernier
            # n'etait jamais ferme avant </nav>.
            out.append('<p style="margin:0"><a href="/?page_id=%d" style="%s">%s</a></p>'
                       % (p['id'], S_A_ON if on else S_A, p['title']['raw']))
    out.append('<p style="%s">%s</p>' % (S_H, 'TELECHARGER' if lang == 'fr' else 'DOWNLOAD'))
    out.append('<p style="margin:0"><a href="/?page_id=%d" style="%s">%s</a></p>'
               % (1111 if lang == 'fr' else 1339, S_A,
                  'Telechargement' if lang == 'fr' else 'Download'))
    out.append('</nav>')
    return ''.join(out)


COL_OPEN = ('<div ' + ATTR_COLS + ' style="display:flex;gap:2rem;flex-wrap:wrap;'
            'align-items:flex-start">')
CONTENT_OPEN = '<div ' + ATTR_COL + ' style="flex:3 1 480px;min-width:0">'
COL_CLOSE = '</div></div>'


def strip_old(raw):
    """Retire une insertion precedente, pour que le script soit rejouable."""
    # Ancienne forme, a commentaires marqueurs.
    raw = re.sub(re.escape(MARK_NAV_A) + '.*?' + re.escape(MARK_NAV_B), '', raw, flags=re.S)
    raw = raw.replace('<div style="display:flex;gap:2rem;flex-wrap:wrap;align-items:flex-start">\n', '')
    raw = raw.replace('<div style="flex:3 1 480px;min-width:0">\n', '')
    raw = raw.replace('</div><!-- wd-colonne -->\n', '').replace('</div><!-- wd-deux-colonnes -->\n', '')
    # Forme courante, a attributs.
    raw = re.sub(r'<nav ' + re.escape(ATTR_NAV) + r'.*?</nav>\n?', '', raw, flags=re.S)
    raw = re.sub(r'<div ' + re.escape(ATTR_COLS) + r'[^>]*>\n?', '', raw)
    raw = re.sub(r'<div ' + re.escape(ATTR_COL) + r'[^>]*>\n?', '', raw)
    raw = raw.replace(COL_CLOSE + '\n', '')
    return raw


def wrap_doc(raw, nav):
    """Pose l'index en colonne, le contenu existant a droite."""
    raw = strip_old(raw)

    # Le pied « Autres guides » disait la meme chose que l'index, en moins
    # complet et differemment sur chaque page. Il part avec sa regle de
    # separation.
    raw = re.sub(r'(<hr[^>]*>\s*)?<!--\s*(AUTRES GUIDES|OTHER GUIDES)\s*-->.*?</p>\s*',
                 '', raw, flags=re.S)

    # Le HERO, quand il existe, reste pleine largeur.
    m = re.search(r'<!-- HERO -->\s*<div[^>]*>.*?\n</div>', raw, re.S)
    if m:
        head, body = raw[:m.end()], raw[m.end():]
    else:
        # Sinon on ouvre juste apres le conteneur du site.
        m2 = re.search(r'<div class="wd-doc">\s*\n', raw)
        if m2:
            head, body = raw[:m2.end()], raw[m2.end():]
        else:
            head, body = '', raw

    body = body.rstrip('\n')
    # Le conteneur du site se referme en dernier : on insere avant lui.
    if body.endswith('</div>'):
        body, tail = body[:-len('</div>')].rstrip('\n'), '\n</div>\n'
    else:
        tail = '\n'

    # Aucune ligne vide entre les balises inserees : une ligne vide declenche
    # un paragraphe chez wpautop, et ce paragraphe devient un element flex.
    return (head.rstrip('\n') + '\n' + COL_OPEN + '\n' + nav + '\n' + CONTENT_OPEN + '\n'
            + body + '\n' + COL_CLOSE + '\n' + tail.lstrip('\n'))


ANNONCE = {
 'fr': """<div style="max-width:900px;margin:0 auto 2rem;padding:0 1rem">
  <div style="background:#dbeafe;border-left:4px solid #2563eb;padding:1.1rem 1.3rem;border-radius:6px">
    <p style="margin:0 0 .4rem;font-size:.85rem;letter-spacing:.05em;text-transform:uppercase;color:#2563eb;font-weight:600">A venir</p>
    <p style="margin:0 0 .6rem;font-size:1.15rem;font-weight:600;color:#1e293b">
      Une version de d&eacute;monstration sera disponible le mardi 1er septembre &agrave; 10h.
    </p>
    <p style="margin:0;color:#334155;font-size:.98rem">
      Elle se connecte &agrave; vos propres bases Oracle et en tire les m&ecirc;mes rapports que la
      version compl&egrave;te &mdash; en consultation seule&nbsp;: aucune action n'est ex&eacute;cutable sur
      la base. Cinq bases au maximum.
    </p>
  </div>
</div>""",
 'en': """<div style="max-width:900px;margin:0 auto 2rem;padding:0 1rem">
  <div style="background:#dbeafe;border-left:4px solid #2563eb;padding:1.1rem 1.3rem;border-radius:6px">
    <p style="margin:0 0 .4rem;font-size:.85rem;letter-spacing:.05em;text-transform:uppercase;color:#2563eb;font-weight:600">Coming up</p>
    <p style="margin:0 0 .6rem;font-size:1.15rem;font-weight:600;color:#1e293b">
      A demonstration build will be available on Tuesday 1 September at 10:00 (CEST).
    </p>
    <p style="margin:0;color:#334155;font-size:.98rem">
      It connects to your own Oracle databases and produces the same reports as the full
      version &mdash; read-only: no action can be run against the database. Five databases at most.
    </p>
  </div>
</div>"""}


def add_annonce(raw, lang):
    raw = re.sub(re.escape(MARK_ANN_A) + '.*?' + re.escape(MARK_ANN_B) + '\n?', '', raw, flags=re.S)
    bloc = MARK_ANN_A + '\n' + ANNONCE[lang] + '\n' + MARK_ANN_B + '\n'
    m = re.search(r'<!-- HERO -->\s*<div[^>]*>.*?\n</div>', raw, re.S)
    if not m:
        return None
    return raw[:m.end()] + '\n' + bloc + raw[m.end():]


def main():
    apply   = '--apply' in sys.argv
    revert  = '--revert' in sys.argv
    unindex = '--revert-index' in sys.argv
    pages = fetch_pages()
    by_id = {p['id']: p for p in pages}
    changes = []

    if unindex:
        # Retire la colonne d'index du CONTENU, l'annonce restant en place.
        # L'index vit desormais dans la colonne laterale du theme, ou il ne
        # peut plus chevaucher le texte ni se battre avec la largeur maximale
        # de `.wd-doc`.
        for p in pages:
            if p.get('parent') not in DOC_PARENT.values():
                continue
            raw = p['content']['raw']
            new = strip_old(raw)
            if new != raw:
                changes.append((p['id'], p['title']['raw'], 'retrait de la colonne',
                                len(raw), len(new), new))
        print('%d page(s) a nettoyer :\n' % len(changes))
        for pid, title, what, a, b, _ in changes:
            print('  %5d  %-34s %-24s %6d -> %6d o' % (pid, title[:34], what, a, b))
        if not apply:
            print('\n(simulation — relancer avec --revert-index --apply)')
            return
        for pid, title, _w, _a, _b, new in changes:
            api('pages/%d' % pid, 'POST', {'content': new})
            print('  nettoye : %5d  %s' % (pid, title[:50]))
        print('\n%d page(s) nettoyee(s).' % len(changes))
        return

    if revert:
        # Retour arriere : on retire ce que le script a pose, rien d'autre.
        # Le contenu d'origine n'est pas restaure depuis la sauvegarde — il est
        # simplement debarrasse des blocs marques, ce qui laisse intactes les
        # modifications faites entre-temps dans l'editeur WordPress.
        for pid in list(HOME.values()) + [p['id'] for p in pages
                                          if p.get('parent') in DOC_PARENT.values()]:
            raw = by_id[pid]['content']['raw']
            new = re.sub(re.escape(MARK_ANN_A) + '.*?' + re.escape(MARK_ANN_B) + r'\n?',
                         '', raw, flags=re.S)
            new = strip_old(new)
            if new != raw:
                changes.append((pid, by_id[pid]['title']['raw'], 'retrait',
                                len(raw), len(new), new))
        print('%d page(s) a nettoyer :\n' % len(changes))
        for pid, title, what, a, b, _ in changes:
            print('  %5d  %-34s %-28s %6d -> %6d o' % (pid, title[:34], what, a, b))
        if not apply:
            print('\n(simulation — relancer avec --revert --apply)')
            return
        for pid, title, _w, _a, _b, new in changes:
            api('pages/%d' % pid, 'POST', {'content': new})
            print('  nettoye : %5d  %s' % (pid, title[:50]))
        print('\n%d page(s) nettoyee(s).' % len(changes))
        return

    for lang, parent in DOC_PARENT.items():
        kids = [p for p in pages if p.get('parent') == parent]
        by_slug = {p['slug']: p for p in kids}
        for p in kids:
            nav = build_nav(by_slug, lang, p['id'])
            new = wrap_doc(p['content']['raw'], nav)
            if new != p['content']['raw']:
                changes.append((p['id'], p['title']['raw'], 'index en colonne',
                                len(p['content']['raw']), len(new), new))

    for lang, pid in HOME.items():
        p = by_id[pid]
        new = add_annonce(p['content']['raw'], lang)
        if new is None:
            print('  !! HERO introuvable sur %d — page ignoree' % pid)
            continue
        if new != p['content']['raw']:
            changes.append((pid, p['title']['raw'], 'annonce de la demonstration',
                            len(p['content']['raw']), len(new), new))

    print('%d page(s) a modifier :\n' % len(changes))
    for pid, title, what, a, b, _ in changes:
        print('  %5d  %-34s %-28s %6d -> %6d o' % (pid, title[:34], what, a, b))

    if not apply:
        print('\n(simulation — rien n\'a ete ecrit ; relancer avec --apply)')
        return

    print()
    for pid, title, what, _a, _b, new in changes:
        api('pages/%d' % pid, 'POST', {'content': new})
        print('  ecrit : %5d  %s' % (pid, title[:50]))
    print('\n%d page(s) mise(s) a jour.' % len(changes))


if __name__ == '__main__':
    main()
