#!/usr/bin/env python3
"""Refonte de l'interface du Fil (CDC du 19/10/2026), partie serveur : RECHERCHE (texte, auteurs affichés, thèmes ; onglet actif ; curseur conservé ; caractères spéciaux ; confidentialité du nom de famille),
PREMIER COMMENTAIRE de chaque publication (le plus ancien de premier niveau visible pour le lecteur) et PHOTO DE PROFIL de l'auteur (adresse sûre seulement)."""
import json, os, re, sys
from urllib.parse import quote
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from lib import call, mk_user, ok, reset_rate_limits, sql, summary

def section(t): print('\n■ ' + t); reset_rate_limits()
def feed(who, qs=''): st, d, _ = call('GET', who, '/posts?limit=50' + qs); assert st == 200, (st, d); return d
def ids(d): return [p['id'] for p in d['items']]
def S(who, q, tab='explorer', extra=''): return ids(feed(who, f'&tab={tab}&q={quote(q)}{extra}'))
def mkpost(uid, theme, body, minutes, kind='member', vis='all'):
    return int(sql(f"INSERT INTO posts (user_id, theme_id, body, visibility, created_at, published_at, kind) VALUES ('{uid}', {theme}, '{body}', '{vis}', NOW() - INTERVAL {minutes} MINUTE, NOW() - INTERVAL {minutes} MINUTE, '{kind}'); SELECT LAST_INSERT_ID()").split()[-1])
def wipe():
    p = "'fs\\_%'"
    sql(f"DELETE FROM comment_likes WHERE user_id LIKE {p}; DELETE FROM comments WHERE user_id LIKE {p} OR post_id IN (SELECT id FROM posts WHERE user_id LIKE {p})")
    for t in ('post_likes', 'post_saves', 'post_media', 'auto_post_refs'): sql(f"DELETE FROM {t} WHERE post_id IN (SELECT id FROM posts WHERE user_id LIKE {p})")
    sql(f"DELETE FROM posts WHERE user_id LIKE {p}; DELETE FROM notifications WHERE user_id LIKE {p} OR actor_id LIKE {p}; DELETE FROM member_follows WHERE follower_id LIKE {p} OR followed_id LIKE {p}")

wipe(); sql("DELETE FROM users WHERE id LIKE 'fs\\_%'")
for uid, f, l in [('fs_a', 'Marie', 'Dupont'), ('fs_b', 'Bruno', 'Martin'), ('fs_c', 'Chloé', 'Étienne'), ('fs_d', 'Dan', 'Durand'), ('fs_v', 'Vera', 'Vidal')]: mk_user(uid, 'client_essentiel', f, l)
sql("UPDATE users SET created_at = NOW() - INTERVAL 60 DAY WHERE id LIKE 'fs\\_%'")
TH = [r.split('\t') for r in sql("SELECT id, label_fr, label_en FROM post_themes WHERE active = 1 AND system_key IS NULL ORDER BY id LIMIT 2").split('\n')]; T1, T2 = int(TH[0][0]), int(TH[1][0]); L1, E1, L2 = TH[0][1], TH[0][2], TH[1][1]
TA = int(sql("SELECT id FROM post_themes WHERE system_key = 'progress'"))
P1 = mkpost('fs_a', T1, 'Mon atelier de céramique ouvre ses portes', 50); P2 = mkpost('fs_b', T2, 'Tarifs 100% transparents pour mes clients', 40); P3 = mkpost('fs_c', T1, 'Le grain du bois ébéniste', 30)
P4 = mkpost('fs_d', T2, 'Un_souligné et un back\\\\slash', 20); P5 = mkpost('fs_a', T2, 'Atelier ouvert samedi', 10); P6 = mkpost('fs_b', TA, '', 5, kind='auto')

section("Recherche : texte, auteur, thème")
ok(S('fs_v', 'céramique') == [P1], 'le TEXTE : « céramique » trouve la publication qui le contient, et elle seule')
ok(S('fs_v', 'CERAMIQUE') == [P1] and S('fs_v', 'ebeniste') == [P3] and S('fs_v', 'Ébéniste') == [P3], 'insensible à la casse ET aux accents (« CERAMIQUE », « ebeniste », « Ébéniste »)')
ok(set(S('fs_v', 'Marie')) == {P1, P5}, 'l\'AUTEUR par son prénom : toutes les publications de Marie')
ok(set(S('fs_v', 'Marie D.')) == {P1, P5} and set(S('fs_v', 'marie d')) == {P1, P5}, 'l\'auteur « Prénom N. » (ce qui est affiché) ou prénom + initiale')
ok(S('fs_v', 'Dupont') == [] and S('fs_v', 'Martin') == [] and S('fs_v', 'Étienne') == [], 'CONFIDENTIALITÉ : le NOM DE FAMILLE complet ne trouve rien (le Fil n\'affiche que « Prénom N. » : une recherche par nom complet permettrait de le deviner)')
ok(set(S('fs_v', L1)) == {P1, P3} and set(S('fs_v', E1)) == {P1, P3} and set(S('fs_v', L2)) == {P2, P4, P5}, f'le THÈME, en français et en anglais ({L1} / {E1} / {L2})')
ok(S('fs_v', 'atelier samedi') == [P5] and S('fs_v', 'marie samedi') == [P5] and S('fs_v', 'marie transparents') == [], 'plusieurs mots : TOUS doivent se trouver, chacun dans le texte, l\'auteur ou le thème')
ok(S('fs_v', 'zzzzqx') == [], 'aucun résultat : liste vide')
ok(set(S('fs_v', 'Marie')) == {P1, P5} and P6 not in S('fs_v', 'Marie'), 'une avancée automatique est trouvée par son auteur ou son thème, pas par un texte qu\'elle n\'a pas'); ok(S('fs_v', 'Bruno') == [P6, P2] or set(S('fs_v', 'Bruno')) == {P2, P6}, 'les avancées de Bruno sont trouvées par son prénom')
ok(S('fs_v', 'Avancées') == [P6], 'le thème réservé « Avancées » trouve les avancées automatiques')

section("Recherche : caractères spéciaux, limites, injection")
ok(S('fs_v', '100%') == [P2], 'le pourcentage est cherché TEL QUEL (« 100% » ne trouve que la publication qui le contient)')
ok(S('fs_v', '%%') == [] and S('fs_v', '__') == [], '« % » et « _ » ne sont pas des jokers : « %% » et « __ » ne trouvent pas tout')
ok(S('fs_v', 'Un_souligné') == [P4], 'le tiret bas est cherché tel quel (« Un_souligné »)')
ok(S('fs_v', 'back\\slash') == [P4], 'l\'antislash aussi')
inj = True
for q in ("' OR 1=1 --", "'; DROP TABLE posts; --", "\" OR \"\"=\"", "%' OR '1'='1"): st, d, _ = call('GET', 'fs_v', f'/posts?tab=explorer&limit=50&q={quote(q)}'); inj &= (st == 200 and ids(d) == [] ); 
ok(inj and int(sql("SELECT COUNT(*) FROM posts WHERE user_id LIKE 'fs\\_%'")) == 6, 'injections SQL (4 formes) : 200, aucun résultat, la table est intacte')
all_ = ids(feed('fs_v', '&tab=explorer')); ok(len(all_) >= 6 and S('fs_v', 'z') == all_ and S('fs_v', '') == all_ and S('fs_v', '   ') == all_ and S('fs_v', ' é ') == all_, 'moins de 2 caractères (ou des espaces) : PAS de recherche, la liste complète')
ok(S('fs_v', 'ce') != all_, '2 caractères : la recherche s\'applique')
st, d, _ = call('GET', 'fs_v', '/posts?tab=explorer&limit=50&q=' + quote('é' * 500)); ok(st == 200 and ids(d) == [], 'un très long texte (500 caractères) est tronqué à 80 : aucune erreur')
ok(S('fs_v', 'mon atelier de céramique ouvre mot absent partout xyzabc') == [P1], 'plus de 5 mots : les 5 PREMIERS seulement comptent (les suivants, absents partout, sont ignorés), sans erreur')

section("Recherche : onglet actif, blocages, curseur")
call('POST', 'fs_v', '/follows', {'user_id': 'fs_a', 'follow': True}) if False else sql("INSERT IGNORE INTO member_follows (follower_id, followed_id, created_at) VALUES ('fs_v', 'fs_a', NOW())")
ok(S('fs_v', 'atelier', 'network') == [P5, P1] or set(S('fs_v', 'atelier', 'network')) == {P1, P5}, 'onglet « Ton réseau » : la recherche ne porte que sur les membres suivis')
ok(S('fs_v', 'transparents', 'network') == [], '… un membre non suivi n\'apparaît pas, même si le texte correspond')
call('POST', 'fs_v', f'/posts/{P3}/save', {}); call('POST', 'fs_v', f'/posts/{P5}/save', {})
ok(set(S('fs_v', 'bois', 'saved')) == {P3} and S('fs_v', 'transparents', 'saved') == [] and set(S('fs_v', 'atelier', 'saved')) == {P5}, 'onglet « Enregistrés » : la recherche ne porte que sur les publications enregistrées')
call('POST', 'fs_v', '/blocks', {'user_id': 'fs_a', 'block': True}); ok(S('fs_v', 'atelier') == [] and S('fs_v', 'Marie') == [], 'un membre BLOQUÉ n\'apparaît jamais dans les résultats (ni par son texte, ni par son nom)'); call('POST', 'fs_v', '/blocks', {'user_id': 'fs_a', 'block': False})
hidden = mkpost('fs_b', T1, 'Réservé au réseau de Bruno', 3, vis='network'); ok(hidden not in S('fs_v', 'Réservé') and hidden in S('fs_b', 'Réservé'), 'une publication « réseau » d\'un membre qui ne vous suit pas n\'est pas trouvée (sa visibilité s\'applique), mais son auteur la trouve')
for i in range(25): mkpost('fs_c', T1, f'Cercle {i} en grès', 100 + i)
out, cur = [], ''
for _ in range(10):
    st, d, _ = call('GET', 'fs_v', '/posts?tab=explorer&limit=7&q=' + quote('Cercle') + (f'&before={cur}' if cur else '')); out += ids(d)
    if not d['has_more']: break
    cur = d['next_before']
ok(len(out) == 25 and len(set(out)) == 25 and ok is not None, 'recherche paginée par pages de 7 : les 25 résultats, sans doublon ni trou (le curseur est conservé)')
ok(len(S('fs_v', 'Cercle', extra='&theme=' + str(T2))) == 0 and len(S('fs_v', 'Cercle', extra='&theme=' + str(T1))) == 25, 'la recherche se combine avec le filtre de thème')
hide = call('PUT', 'fs_v', '/auto-posts/preference', {'hide_auto': True}); ok(P6 not in S('fs_v', 'Bruno') and P2 in S('fs_v', 'Bruno'), 'et avec le réglage « masquer les avancées »'); call('PUT', 'fs_v', '/auto-posts/preference', {'hide_auto': False})

section("Premier commentaire")
reset_rate_limits(); st0 = {p['id']: p for p in feed('fs_v', '&tab=explorer')['items']}; ok(st0[P1]['first_comment'] is None and 'first_comment' in st0[P1], 'une publication SANS commentaire : first_comment présent et nul')
c1 = call('POST', 'fs_b', f'/posts/{P1}/comments', {'body': 'Premier commentaire de Bruno'})[1]['comment']['id']; c2 = call('POST', 'fs_c', f'/posts/{P1}/comments', {'body': 'Deuxième de Chloé'})[1]['comment']['id']
r1 = call('POST', 'fs_d', f'/posts/{P1}/comments', {'body': 'Réponse de Dan au premier', 'parent_id': c1})[1]['comment']['id']
f = {p['id']: p for p in feed('fs_v', '&tab=explorer')['items']}[P1]; fc = f['first_comment']
ok(fc and fc['id'] == c1 and fc['body'] == 'Premier commentaire de Bruno' and fc['author']['name'] == 'Bruno M.' and fc['parent_id'] is None, 'le premier commentaire est le PLUS ANCIEN de premier niveau (Bruno), pas une réponse')
ok(f['comments'] == 3 and fc['post_id'] == P1, 'le total « comments » (3 : deux commentaires et une réponse) est le même que pour la bulle')
ok(all(k in fc for k in ('author', 'likes', 'liked_by_me', 'mine', 'created_at', 'edited', 'mentions')) and fc['likes'] == 0 and fc['liked_by_me'] is False and fc['mine'] is False, 'il porte tout ce qu\'il faut à l\'affichage : auteur, j\'aime, état du lecteur, date, mentions')
call('POST', 'fs_v', f'/posts/{P1}/comments/{c1}/like', {}); f = {p['id']: p for p in feed('fs_v', '&tab=explorer')['items']}[P1]; ok(f['first_comment']['likes'] == 1 and f['first_comment']['liked_by_me'] is True, 'les j\'aime du premier commentaire sont à jour et propres au lecteur')
ok({p['id']: p for p in feed('fs_b', '&tab=explorer')['items']}[P1]['first_comment']['mine'] is True, 'pour Bruno, son commentaire est « mine »')
sql(f"UPDATE comments SET status = 'hidden' WHERE id = {c1}"); f = {p['id']: p for p in feed('fs_v', '&tab=explorer')['items']}[P1]; ok(f['first_comment']['id'] == c2 and f['comments'] == 1, 'un commentaire MASQUÉ par la modération passe son tour : le suivant devient le premier, et le total ne compte ni lui ni sa réponse'); sql(f"UPDATE comments SET status = 'published' WHERE id = {c1}")
call('POST', 'fs_v', '/blocks', {'user_id': 'fs_b', 'block': True}); f = {p['id']: p for p in feed('fs_v', '&tab=explorer')['items']}[P1]; ok(f['first_comment']['id'] == c2, 'le commentaire d\'un membre BLOQUÉ par le lecteur est ignoré : on montre le suivant'); call('POST', 'fs_v', '/blocks', {'user_id': 'fs_b', 'block': False})
ok({p['id']: p for p in feed('fs_c', '&tab=explorer')['items']}[P1]['first_comment']['id'] == c1, 'un autre lecteur voit le vrai premier commentaire (le filtrage est propre au lecteur)')
call('POST', 'fs_b', f'/posts/{P6}/comments', {'body': 'Bravo Bruno !'}); call('POST', 'fs_c', f'/posts/{P6}/comments', {'body': 'Courage'}); a6 = {p['id']: p for p in feed('fs_c', '&tab=explorer')['items']}[P6]; ok(a6['first_comment']['body'] == 'Bravo Bruno !', 'sur une publication AUTOMATIQUE : le premier encouragement')
st, d, _ = call('GET', 'fs_c', f'/posts/{P1}'); ok(st == 200 and d['post']['first_comment']['id'] == c1, 'une publication lue seule (ciblée) porte aussi son premier commentaire')
cs = call('GET', 'fs_v', f'/posts/{P1}/comments')[1]['items']; ok(len(cs) == 3 and cs[0]['id'] == c1, 'la liste complète des commentaires est inchangée (3, dans l\'ordre)')

reset_rate_limits(); P7 = mkpost('fs_a', T1, 'Publication aux commentaires imbriqués', 2)
ca = call('POST', 'fs_b', f'/posts/{P7}/comments', {'body': 'Racine de Bruno'})[1]['comment']['id']; call('POST', 'fs_c', f'/posts/{P7}/comments', {'body': 'Réponse de Chloé à Bruno', 'parent_id': ca}); cc = call('POST', 'fs_d', f'/posts/{P7}/comments', {'body': 'Racine de Dan'})[1]['comment']['id']
g7 = lambda who: {p['id']: p for p in feed(who, '&tab=explorer')['items']}[P7]['first_comment']
ok(g7('fs_v')['id'] == ca, 'publication à commentaires imbriqués : le premier est la racine la plus ancienne')
call('POST', 'fs_v', '/blocks', {'user_id': 'fs_b', 'block': True}); ok(g7('fs_v')['id'] == cc and g7('fs_v')['parent_id'] is None, 'la racine est ignorée (auteur bloqué) : on montre la racine SUIVANTE (Dan), JAMAIS la réponse de Chloé qui la précède (une réponse ne compte pas comme aperçu)'); call('POST', 'fs_v', '/blocks', {'user_id': 'fs_b', 'block': False})

section("Photo de profil de l'auteur")
sql("UPDATE users SET avatar_url = '/uploads/artisans/fs_a/avatar.webp' WHERE id = 'fs_a'"); sql("UPDATE users SET avatar_url = 'https://cdn.exemple.fr/photo.jpg' WHERE id = 'fs_b'"); sql("UPDATE users SET avatar_url = 'javascript:alert(1)' WHERE id = 'fs_c'"); sql("UPDATE users SET avatar_url = 'http://non-securise.fr/p.jpg' WHERE id = 'fs_d'")
fd = {p['id']: p for p in feed('fs_v', '&tab=explorer')['items']}
ok(fd[P1]['author']['avatar_url'] == '/uploads/artisans/fs_a/avatar.webp' and fd[P2]['author']['avatar_url'] == 'https://cdn.exemple.fr/photo.jpg', 'une photo du site (/uploads/…) ou en https est renvoyée')
ok(fd[P3]['author']['avatar_url'] is None and fd[P4]['author']['avatar_url'] is None, 'une adresse « javascript: » ou en « http: » n\'est JAMAIS renvoyée (nulle)')
sql("UPDATE users SET avatar_url = NULL WHERE id = 'fs_a'"); ok({p['id']: p for p in feed('fs_v', '&tab=explorer')['items']}[P1]['author']['avatar_url'] is None, 'sans photo : nulle (l\'interface affiche les initiales)')
fc = {p['id']: p for p in feed('fs_v', '&tab=explorer')['items']}[P1]['first_comment']; ok('avatar_url' in fc['author'] and fc['author']['avatar_url'] == 'https://cdn.exemple.fr/photo.jpg', 'les commentaires portent aussi la photo de leur auteur')
ok(all('avatar_url' in c['author'] for c in cs), 'la liste des commentaires aussi')
wipe(); sql("DELETE FROM users WHERE id LIKE 'fs\\_%'"); summary()
