#!/usr/bin/env python3
"""Badge à côté du nom et pastille « actif » (CDC n° 20) : niveau global (parité avec le site), badge selon badge_kind, activité significative,
fenêtre, réciprocité « Afficher mon activité », aucune date exposée."""
import json, os, subprocess, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from lib import ROOT, call, mk_user, ok, reset_rate_limits, sql, summary

# 136 cas (total de points, sources déclenchées, niveau) tirés du VRAI code du site (js/level-manager.js, computeGlobalLevel).
LEVEL_CASES = json.loads('[[0,[],1],[0,["action_directe"],1],[0,["action_directe","profil_rempli"],2],[0,["suivi"],1],[0,["action_directe","profil_rempli","suivi"],3],[0,["contact_invite"],1],[0,["action_directe","profil_rempli","suivi","contact_invite"],3],[0,["quiz","question"],1],[1,[],1],[1,["action_directe"],1],[1,["action_directe","profil_rempli"],2],[1,["suivi"],1],[1,["action_directe","profil_rempli","suivi"],3],[1,["contact_invite"],1],[1,["action_directe","profil_rempli","suivi","contact_invite"],3],[1,["quiz","question"],1],[29,[],1],[29,["action_directe"],1],[29,["action_directe","profil_rempli"],2],[29,["suivi"],1],[29,["action_directe","profil_rempli","suivi"],3],[29,["contact_invite"],1],[29,["action_directe","profil_rempli","suivi","contact_invite"],3],[29,["quiz","question"],1],[30,[],2],[30,["action_directe"],2],[30,["action_directe","profil_rempli"],2],[30,["suivi"],3],[30,["action_directe","profil_rempli","suivi"],3],[30,["contact_invite"],2],[30,["action_directe","profil_rempli","suivi","contact_invite"],3],[30,["quiz","question"],2],[31,[],2],[31,["action_directe"],2],[31,["action_directe","profil_rempli"],2],[31,["suivi"],3],[31,["action_directe","profil_rempli","suivi"],3],[31,["contact_invite"],2],[31,["action_directe","profil_rempli","suivi","contact_invite"],3],[31,["quiz","question"],2],[59,[],2],[59,["action_directe"],2],[59,["action_directe","profil_rempli"],2],[59,["suivi"],3],[59,["action_directe","profil_rempli","suivi"],3],[59,["contact_invite"],2],[59,["action_directe","profil_rempli","suivi","contact_invite"],3],[59,["quiz","question"],2],[60,[],2],[60,["action_directe"],2],[60,["action_directe","profil_rempli"],3],[60,["suivi"],3],[60,["action_directe","profil_rempli","suivi"],3],[60,["contact_invite"],2],[60,["action_directe","profil_rempli","suivi","contact_invite"],3],[60,["quiz","question"],2],[89,[],2],[89,["action_directe"],2],[89,["action_directe","profil_rempli"],3],[89,["suivi"],3],[89,["action_directe","profil_rempli","suivi"],3],[89,["contact_invite"],2],[89,["action_directe","profil_rempli","suivi","contact_invite"],3],[89,["quiz","question"],2],[90,[],3],[90,["action_directe"],3],[90,["action_directe","profil_rempli"],3],[90,["suivi"],3],[90,["action_directe","profil_rempli","suivi"],4],[90,["contact_invite"],3],[90,["action_directe","profil_rempli","suivi","contact_invite"],4],[90,["quiz","question"],3],[119,[],3],[119,["action_directe"],3],[119,["action_directe","profil_rempli"],3],[119,["suivi"],3],[119,["action_directe","profil_rempli","suivi"],4],[119,["contact_invite"],3],[119,["action_directe","profil_rempli","suivi","contact_invite"],4],[119,["quiz","question"],3],[120,[],3],[120,["action_directe"],3],[120,["action_directe","profil_rempli"],3],[120,["suivi"],4],[120,["action_directe","profil_rempli","suivi"],4],[120,["contact_invite"],3],[120,["action_directe","profil_rempli","suivi","contact_invite"],4],[120,["quiz","question"],3],[180,[],4],[180,["action_directe"],4],[180,["action_directe","profil_rempli"],4],[180,["suivi"],4],[180,["action_directe","profil_rempli","suivi"],4],[180,["contact_invite"],4],[180,["action_directe","profil_rempli","suivi","contact_invite"],4],[180,["quiz","question"],4],[181,[],4],[181,["action_directe"],4],[181,["action_directe","profil_rempli"],4],[181,["suivi"],4],[181,["action_directe","profil_rempli","suivi"],4],[181,["contact_invite"],4],[181,["action_directe","profil_rempli","suivi","contact_invite"],4],[181,["quiz","question"],4],[300,[],5],[300,["action_directe"],5],[300,["action_directe","profil_rempli"],5],[300,["suivi"],5],[300,["action_directe","profil_rempli","suivi"],5],[300,["contact_invite"],6],[300,["action_directe","profil_rempli","suivi","contact_invite"],6],[300,["quiz","question"],5],[450,[],6],[450,["action_directe"],6],[450,["action_directe","profil_rempli"],6],[450,["suivi"],6],[450,["action_directe","profil_rempli","suivi"],6],[450,["contact_invite"],6],[450,["action_directe","profil_rempli","suivi","contact_invite"],7],[450,["quiz","question"],6],[1000,[],8],[1000,["action_directe"],8],[1000,["action_directe","profil_rempli"],8],[1000,["suivi"],8],[1000,["action_directe","profil_rempli","suivi"],9],[1000,["contact_invite"],9],[1000,["action_directe","profil_rempli","suivi","contact_invite"],9],[1000,["quiz","question"],8],[5000,[],18],[5000,["action_directe"],18],[5000,["action_directe","profil_rempli"],18],[5000,["suivi"],18],[5000,["action_directe","profil_rempli","suivi"],18],[5000,["contact_invite"],19],[5000,["action_directe","profil_rempli","suivi","contact_invite"],19],[5000,["quiz","question"],18]]')

def setting(k, v): sql(f"INSERT INTO messaging_settings (setting_key, setting_value) VALUES ('{k}', '{json.dumps(v)}') ON DUPLICATE KEY UPDATE setting_value = '{json.dumps(v)}'")
def unset(k): sql(f"DELETE FROM messaging_settings WHERE setting_key = '{k}'")
def section(t): print('\n■ ' + t); reset_rate_limits()
def last(uid): return sql(f"SELECT COALESCE(last_active_at, 'NULL') FROM users WHERE id = '{uid}'").strip()
def set_last(uid, sqlexpr): sql(f"UPDATE users SET last_active_at = {sqlexpr} WHERE id = '{uid}'")
def publish(who, body='Un texte'):
    st, d, _ = call('POST', who, '/posts', {'body': body, 'theme_id': THEME}); assert st == 201, (st, d); return d['post']
def author_of(viewer, post_id):
    st, d, _ = call('GET', viewer, f'/posts/{post_id}'); assert st == 200, (st, d); return d['post']['author']
def expected_level(total, sources):
    return next(c[2] for c in LEVEL_CASES if c[0] == total and sorted(c[1]) == sorted(sources))
RESET_ACT = "UPDATE users SET last_active_at = NULL WHERE id LIKE 'a\\_%'"

def wipe(prefix):
    """Supprime TOUT ce que les membres de test ont laissé : publications, commentaires, j'aime, mentions, notifications, abonnements."""
    p = f"'{prefix}\\_%'"
    sql(f"DELETE cl FROM comment_likes cl JOIN comments c ON c.id = cl.comment_id WHERE c.user_id LIKE {p} OR c.post_id IN (SELECT id FROM posts WHERE user_id LIKE {p})")
    sql(f"DELETE FROM mentions WHERE member_id LIKE {p} OR (subject_type = 'post' AND subject_id IN (SELECT id FROM posts WHERE user_id LIKE {p})) OR (subject_type = 'comment' AND subject_id IN (SELECT id FROM comments WHERE user_id LIKE {p}))")
    sql(f"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'): sql(f"DELETE FROM {t} WHERE post_id IN (SELECT id FROM posts WHERE user_id LIKE {p})")
    sql(f"DELETE FROM post_likes WHERE user_id LIKE {p}; DELETE FROM post_saves WHERE user_id LIKE {p}; DELETE FROM posts WHERE user_id LIKE {p}")
    sql(f"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('a'); sql("DELETE FROM users WHERE id LIKE 'a\\_%'")
mk_user('a_admin', 'admin', 'Alice', 'Admin'); mk_user('a_resp', 'responsable', 'Rita', 'Resp')
mk_user('a_bob', 'client_premium', 'Bob', 'Martin'); mk_user('a_cat', 'prospect', 'Cathy', 'Durand'); mk_user('a_dan', 'client_essentiel', 'Dan', 'Petit')
THEME = int(sql("SELECT id FROM post_themes WHERE active = 1 AND system_key IS NULL ORDER BY id LIMIT 1").strip())
sql("UPDATE users SET craft = 'Céramiste' WHERE id = 'a_bob'; UPDATE users SET craft = '' WHERE id IN ('a_cat', 'a_dan')")
for k in ('badge_kind', 'activity_window_days'): unset(k)

section("Migration")
cols = sql("SELECT COLUMN_NAME, IS_NULLABLE, COLUMN_DEFAULT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users' AND COLUMN_NAME IN ('last_active_at', 'show_activity') ORDER BY COLUMN_NAME").split('\n')
ok(len(cols) == 2 and cols[0].startswith('last_active_at\tYES') and cols[1].startswith('show_activity\tNO\t1'), 'users.last_active_at (vide au départ) et users.show_activity (1 par défaut) existent')
ok(last('a_bob') == 'NULL' and sql("SELECT show_activity FROM users WHERE id = 'a_bob'").strip() == '1', 'un membre existant : jamais actif, activité affichée')

section("Niveau global : PARITÉ avec le code du site")
code = '$_SERVER["REQUEST_METHOD"] = "GET"; require "api/_levels_global.php"; foreach (json_decode(stream_get_contents(STDIN), true) as $c) echo global_level_from_points($c[0], $c[1]), "\\n";'
out = subprocess.run(['php', '-d', 'display_errors=0', '-d', 'error_reporting=0', '-r', code], input=json.dumps(LEVEL_CASES), capture_output=True, text=True, cwd=ROOT).stdout.split()
bad = [(c, o) for c, o in zip(LEVEL_CASES, out) if int(o) != c[2]]
ok(len(LEVEL_CASES) == 136 and len(out) == 136 and not bad, "les 136 cas tirés du vrai js/level-manager.js donnent le MÊME niveau côté serveur (niveaux 1 à 19, exceptions comprises)" + (f" — écarts : {bad[:3]}" if bad else ''))
ok(len({c[2] for c in LEVEL_CASES}) >= 10, 'la grille couvre au moins 10 niveaux différents')

section("Niveau lu en base (badge « niveau Uluana »)")
setting('badge_kind', 'level')
sql("DELETE FROM user_points WHERE user_id IN ('a_bob', 'a_cat', 'a_dan')")
sql("INSERT INTO user_points (user_id, category, source, points) VALUES ('a_cat', 'c1', 'quiz', 20), ('a_cat', 'c2', 'quiz', 10)")
sql("INSERT INTO user_points (user_id, category, source, points) VALUES ('a_dan', 'c1', 'action_directe', 20), ('a_dan', 'c2', 'profil_rempli', 20), ('a_dan', 'c3', 'suivi', 20)")
pb, pc, pd = publish('a_bob', 'de Bob')['id'], publish('a_cat', 'de Cathy')['id'], publish('a_dan', 'de Dan')['id']
ab, ac, ad = author_of('a_admin', pb), author_of('a_admin', pc), author_of('a_admin', pd)
ok(ab['badge_level'] == 1 and ab['badge'] == 'Niveau 1' and ab['badge_kind'] == 'level', 'sans aucun point : « Niveau 1 »')
ok(ac['badge_level'] == expected_level(30, []) == 2 and ac['badge'] == 'Niveau 2', f"30 points répartis sur 2 catégories : niveau {ac['badge_level']} (les catégories s'additionnent)")
ok(ad['badge_level'] == expected_level(60, ['action_directe', 'profil_rempli', 'suivi']) == 3, f"60 points et les sources « action directe », « profil rempli », « suivi » : niveau {ad['badge_level']} (les exceptions de niveau jouent)")
st, d, _ = call('GET', 'a_admin', '/posts?tab=explorer'); lv = {x['author']['id']: x['author']['badge_level'] for x in d['items'] if x['author']['id'] in ('a_bob', 'a_cat', 'a_dan')}
ok(lv == {'a_bob': 1, 'a_cat': 2, 'a_dan': 3}, 'le fil donne les niveaux de plusieurs auteurs en une seule réponse')

section("Badge selon le paramètre badge_kind")
setting('badge_kind', 'specialty'); a = author_of('a_admin', pb)
ok(a['badge'] == 'Céramiste' and a['badge_kind'] == 'specialty' and a['badge_level'] is None, 'spécialité (défaut) : « Céramiste », pas de niveau')
ok(author_of('a_admin', pc)['badge'] is None, 'spécialité vide : aucun badge (jamais une étiquette vide)')
setting('badge_kind', 'status'); ok(author_of('a_admin', pb)['badge'] == 'Premium' and author_of('a_admin', pd)['badge'] == 'Essentiel' and author_of('a_admin', pc)['badge'] is None, 'statut : Premium, Essentiel ; un prospect n\'a pas d\'étiquette')
unset('badge_kind'); ok(author_of('a_admin', pb)['badge_kind'] == 'specialty', 'sans réglage : la spécialité est le défaut')
st, d, _ = call('POST', 'a_dan', f'/posts/{pb}/comments', {'body': 'Joli'}); cm = call('GET', 'a_admin', f'/posts/{pb}/comments')[1]['items'][0]['author']
ok(st == 201 and cm['id'] == 'a_dan' and set(cm) == {'id', 'name', 'avatar_url', 'badge', 'badge_kind', 'badge_level', 'active'} and cm['avatar_url'] is None, 'les auteurs de COMMENTAIRES portent aussi le badge et l\'indicateur d\'activité, et leur photo de profil (nulle sans photo)')

section("Enregistrer l'activité (action significative, au plus une fois toutes les 5 minutes)")
sql(RESET_ACT)
p1 = publish('a_bob', 'Premier'); t1 = last('a_bob'); ok(t1 != 'NULL', 'publier : la date d\'activité est enregistrée')
set_last('a_bob', "UTC_TIMESTAMP() - INTERVAL 2 MINUTE"); before = last('a_bob'); publish('a_bob', 'Deuxième'); ok(last('a_bob') == before, 'une seconde action 2 minutes après : la date ne bouge pas (limite de 5 minutes)')
set_last('a_bob', "UTC_TIMESTAMP() - INTERVAL 10 MINUTE"); before = last('a_bob'); publish('a_bob', 'Troisième'); ok(last('a_bob') != before, 'une action 10 minutes après : la date est mise à jour')
set_last('a_bob', "UTC_TIMESTAMP() - INTERVAL 5 MINUTE - INTERVAL 5 SECOND"); before = last('a_bob'); publish('a_bob', 'Limite'); ok(last('a_bob') != before, 'juste au-delà de 5 minutes : mise à jour')
sql(RESET_ACT); reset_rate_limits()
call('POST', 'a_cat', f'/posts/{p1["id"]}/comments', {'body': 'Beau'}); ok(last('a_cat') != 'NULL', 'commenter : action significative')
sql(RESET_ACT)
call('POST', 'a_cat', f'/posts/{p1["id"]}/like', {}); call('POST', 'a_cat', f'/posts/{p1["id"]}/save', {}); call('GET', 'a_cat', '/posts'); call('GET', 'a_cat', '/notifications'); call('GET', 'a_cat', f'/posts/{p1["id"]}/comments')
ok(last('a_cat') == 'NULL', 'aimer, enregistrer, ouvrir le fil, lire ses notifications : une simple ouverture ne compte PAS')
card = sql("SELECT id FROM cards ORDER BY id LIMIT 1").strip() or 'ZZ_TEST_01'          # la base de test n'a pas de cartes : actions.card_id n'a pas de contrainte
if card:
    st, d, _ = call('POST', 'a_dan', '/actions/a_dan', {'card_id': card, 'status': 'en_cours'}); ok(st in (200, 201) and last('a_dan') != 'NULL', f"prendre une action du catalogue : action significative ({st})")
    aid = d.get('id'); sql(RESET_ACT)
    st, d, _ = call('PUT', 'a_dan', f'/actions/a_dan/{aid}', {'status': 'en_cours', 'comment': 'note'}); ok(st == 200 and last('a_dan') == 'NULL', 'modifier un commentaire ou rester « en cours » : pas d\'activité')
    st, d, _ = call('PUT', 'a_dan', f'/actions/a_dan/{aid}', {'status': 'validee'}); ok(st == 200 and last('a_dan') != 'NULL', 'terminer une action (« validée ») : action significative')
    sql(RESET_ACT)
    st, d, _ = call('PUT', 'a_admin', f'/actions/a_dan/{aid}', {'status': 'validee', 'comment': 'par l\'admin'}); ok(st == 200 and last('a_dan') == 'NULL' and last('a_admin') != 'NULL', 'un admin qui agit pour un membre : c\'est l\'ADMIN qui est actif, jamais le membre')
sql(RESET_ACT); reset_rate_limits()
st, d, _ = call('POST', 'a_bob', '/collaboration-requests', {'title': 'Un projet', 'description': 'Pour tester'}); ok(st in (200, 201) and last('a_bob') != 'NULL', f'publier un projet de collaboration : action significative ({st})')

section("La pastille « actif » : fenêtre")
sql("UPDATE users SET show_activity = 1 WHERE id LIKE 'a\\_%'"); pb2 = publish('a_bob', 'Pour la pastille')['id']
set_last('a_bob', 'NULL'); ok(author_of('a_cat', pb2)['active'] is False, 'jamais actif : pas de pastille')
set_last('a_bob', "UTC_TIMESTAMP() - INTERVAL 1 HOUR"); ok(author_of('a_cat', pb2)['active'] is True, 'actif il y a 1 heure : pastille')
set_last('a_bob', "UTC_TIMESTAMP() - INTERVAL 6 DAY - INTERVAL 23 HOUR"); ok(author_of('a_cat', pb2)['active'] is True, 'à 6 jours 23 h (fenêtre de 7 jours par défaut) : toujours actif')
set_last('a_bob', "UTC_TIMESTAMP() - INTERVAL 7 DAY - INTERVAL 1 HOUR"); ok(author_of('a_cat', pb2)['active'] is False, 'à 7 jours 1 h : plus actif')
setting('activity_window_days', 14); ok(author_of('a_cat', pb2)['active'] is True, 'fenêtre portée à 14 jours dans /control : de nouveau actif (le réglage s\'applique tout de suite)')
setting('activity_window_days', 1); set_last('a_bob', "UTC_TIMESTAMP() - INTERVAL 2 DAY"); ok(author_of('a_cat', pb2)['active'] is False, 'fenêtre d\'1 jour : 2 jours = plus actif'); unset('activity_window_days')

section("Réciprocité « Afficher mon activité »")
set_last('a_bob', "UTC_TIMESTAMP() - INTERVAL 1 HOUR")
ok(author_of('a_cat', pb2)['active'] is True, 'départ : Bob actif, Cathy le voit')
sql("UPDATE users SET show_activity = 0 WHERE id = 'a_bob'"); ok(author_of('a_cat', pb2)['active'] is False and author_of('a_bob', pb2)['active'] is False, 'Bob masque son activité : personne ne voit sa pastille, lui non plus')
sql("UPDATE users SET show_activity = 1 WHERE id = 'a_bob'; UPDATE users SET show_activity = 0 WHERE id = 'a_cat'"); ok(author_of('a_cat', pb2)['active'] is False, 'Cathy masque la sienne : elle ne voit PAS non plus celle de Bob (réciprocité)')
ok(author_of('a_dan', pb2)['active'] is True, 'un troisième membre qui a gardé l\'option voit toujours la pastille de Bob')
sql("UPDATE users SET show_activity = 1 WHERE id LIKE 'a\\_%'")

section("Aucune heure d'activité n'est jamais exposée")
st, d, _ = call('GET', 'a_cat', '/posts'); raw = json.dumps(d)
ok('last_active' not in raw and 'active_at' not in raw and 'seen' not in raw.lower(), 'aucune clé « last_active », « active_at » ou « seen » dans le fil')
mine = [x['author'] for x in d['items'] if x['author']['id'] == 'a_bob'][0]
ok(set(mine) == {'id', 'name', 'avatar_url', 'badge', 'badge_kind', 'badge_level', 'active'} and isinstance(mine['active'], bool) and mine['avatar_url'] is None, 'l\'auteur ne porte que { id, nom, photo (nulle sans photo), badge, type de badge, niveau, actif oui / non }')
set_last('a_bob', "UTC_TIMESTAMP() - INTERVAL 1 HOUR"); a1 = json.dumps(author_of('a_cat', pb2)); set_last('a_bob', "UTC_TIMESTAMP() - INTERVAL 3 HOUR"); ok(a1 == json.dumps(author_of('a_cat', pb2)), 'actif il y a 1 h ou il y a 3 h : réponse strictement identique (on ne peut pas deviner l\'heure)')

section("Préférence « Afficher mon activité »")
st, d, _ = call('GET', 'a_bob', '/notification-prefs'); ok(st == 200 and d['activity'] == {'show': True}, 'lecture : activité affichée par défaut')
st, d, _ = call('PUT', 'a_bob', '/notification-prefs', {'activity': {'show': False}}); ok(st == 200 and d['activity'] == {'show': False} and sql("SELECT show_activity FROM users WHERE id = 'a_bob'").strip() == '0', 'désactiver : enregistré, renvoyé, et c\'est la colonne users.show_activity')
set_last('a_bob', "UTC_TIMESTAMP() - INTERVAL 1 HOUR"); ok(author_of('a_dan', pb2)['active'] is False, 'et ça se voit tout de suite dans le fil')
st, d, _ = call('PUT', 'a_bob', '/notification-prefs', {'activity': {'show': True}}); ok(st == 200 and d['activity']['show'] is True and author_of('a_dan', pb2)['active'] is True, 'réactiver : la pastille revient')
st, d, _ = call('PUT', 'a_bob', '/notification-prefs', {'activity': {'show': False}, 'types': {'inconnu': {'in_app': True}}}); ok(st == 422 and 'types.inconnu' in d['errors'] and sql("SELECT show_activity FROM users WHERE id = 'a_bob'").strip() == '1', 'une valeur invalide ailleurs : RIEN n\'est enregistré, activité comprise (atomique)')
for body, why in (({'activity': {'show': 'oui'}}, 'texte'), ({'activity': {'show': None}}, 'null'), ({'activity': {'show': 2}}, 'nombre'), ({'activity': 'non'}, 'pas un objet')):
    st, d, _ = call('PUT', 'a_bob', '/notification-prefs', body); ok(st == 422 and any(k.startswith('activity') for k in d['errors']), f'valeur refusée (422) : {why}')
st, d, _ = call('PUT', 'a_bob', '/notification-prefs', {'activity': {}}); ok(st == 200 and d['activity']['show'] is True, 'un objet vide ne change rien')
ok(call('GET', None, '/notification-prefs')[0] == 401 and call('PUT', None, '/notification-prefs', {'activity': {'show': False}})[0] == 401, 'sans connexion : 401')
st, d, _ = call('PUT', 'a_bob', '/notification-prefs', {}); ok(st == 400 and 'activity' in d['error'], 'corps vide : le message cite « activity »')

sql("DELETE FROM actions WHERE user_id LIKE 'a\\_%'"); sql("DELETE FROM user_points WHERE user_id LIKE 'a\\_%'"); sql("DELETE FROM collaboration_requests WHERE user_id LIKE 'a\\_%'"); unset('badge_kind'); unset('activity_window_days'); wipe('a'); sql("DELETE FROM users WHERE id LIKE 'a\\_%'"); summary()
