#!/usr/bin/env python3
"""Signalement d'un MESSAGE privé ou d'un MEMBRE, modération (lecture journalisée, copie de preuve, actions) et suspension de la messagerie (CDC n° 16, 17, § 7.5, § 7.7)."""
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

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 follow(a, b): sql(f"INSERT IGNORE INTO member_follows (follower_id, followed_id) VALUES ('{a}', '{b}')")
def start(who, to, body='Bonjour'): return call('POST', who, '/conversations', {'user_id': to, 'body': body})
def send(who, cid, body='Salut', **kw): return call('POST', who, f'/conversations/{cid}/messages', {'body': body, **kw})
def report(who, tt, tid, reason='harassment', note=None):
    reset_rate_limits(); d = {'target_type': tt, 'target_id': tid, 'reason': reason}; d.update({'note': note} if note is not None else {}); return call('POST', who, '/reports', d)
def detail(rid, who='r_adm'): return call('GET', who, f'/admin-reports/{rid}')
def act(rid, action, who='r_adm', **kw): return call('POST', who, f'/admin-reports/{rid}/action', {'action': action, **kw})
def cnt(q): return int(sql(q))
def notifs(who, typ='moderation'): return [n for n in call('GET', who, '/notifications')[1]['items'] if n['type'] == typ]
def age(uid, s=120): sql(f"UPDATE messages SET created_at = created_at - INTERVAL {s} SECOND WHERE user_id = '{uid}'")
def php(code): return subprocess.run(['php', '-d', 'display_errors=0', '-r', '$_SERVER["REQUEST_METHOD"]="GET"; require "api/_moderation.php"; ' + code], capture_output=True, text=True, cwd=ROOT).stdout.strip()
def wipe():
    p = "'r\\_%'"
    sql(f"DELETE FROM messages WHERE user_id LIKE {p}; DELETE FROM message_requests WHERE sender_id LIKE {p}; DELETE FROM conversation_members WHERE user_id LIKE {p}; DELETE FROM conversations WHERE created_by LIKE {p}")
    sql(f"DELETE FROM reports WHERE reporter_id LIKE {p} OR reported_user_id LIKE {p}; DELETE FROM moderation_audit WHERE actor_id LIKE {p}; DELETE FROM notifications WHERE user_id LIKE {p} OR actor_id LIKE {p}; DELETE FROM notification_prefs WHERE user_id LIKE {p}; DELETE FROM member_follows WHERE follower_id LIKE {p} OR followed_id LIKE {p}; DELETE FROM posts WHERE user_id LIKE {p}")

wipe(); sql("DELETE FROM users WHERE id LIKE 'r\\_%'")
for uid, role, f, l in [('r_a', 'client_essentiel', 'Anne', 'Adam'), ('r_b', 'client_essentiel', 'Bruno', 'Blanc'), ('r_c', 'client_essentiel', 'Chloé', 'Carré'), ('r_d', 'client_essentiel', 'Dan', 'Dupont'), ('r_adm', 'admin', 'Alice', 'Admin'), ('r_resp', 'responsable', 'Mona', 'Moreau')]: mk_user(uid, role, f, l)
sql("UPDATE users SET created_at = NOW() - INTERVAL 60 DAY WHERE id LIKE 'r\\_%'"); follow('r_a', 'r_b'); follow('r_b', 'r_a'); follow('r_c', 'r_b'); follow('r_b', 'r_c')
for k in ('moderation_context_messages', 'messages_per_minute', 'report_copy_retention_months'): unset(k)
setting('messages_per_minute', 60)
# Conversation Anne – Bruno : 20 messages, Bruno écrit les pairs
st, d, _ = start('r_a', 'r_b', 'M01'); C = d['conversation_id']; M = [d['message']['id']]
for i in range(2, 21): M.append(send('r_b' if i % 2 == 0 else 'r_a', C, f'M{i:02d}')[1]['message']['id'])
st, d, _ = start('r_c', 'r_b', 'Autre conversation'); C2 = d['conversation_id']; X = send('r_b', C2, 'Réponse de Bruno')[1]['message']['id']          # X : un message de BRUNO dans la conversation avec Chloé
# Dans C, Anne écrit M[0], M[2]… (indices pairs) et Bruno M[1], M[3]… (indices impairs) : Anne signale les messages de Bruno, Bruno ceux d'Anne.

section("Signaler un message : validation et droits")
ok(call('POST', None, '/reports', {'target_type': 'message', 'target_id': M[1], 'reason': 'spam'})[0] == 401, 'sans connexion : 401')
for body, key, why in (({'target_type': 'message', 'target_id': M[1]}, 'reason', 'motif absent'), ({'target_type': 'message', 'target_id': M[1], 'reason': 'inconnu'}, 'reason', 'motif inconnu'), ({'target_type': 'message', 'target_id': M[1], 'reason': 'other'}, 'note', '« autre » sans précision'), ({'target_type': 'message', 'target_id': M[1], 'reason': 'spam', 'note': 'x' * 501}, 'note', 'note de 501 caractères'),
                       ({'target_type': 'message', 'target_id': M[1], 'reason': 'spam', 'note': 5}, 'note', 'note qui n\'est pas du texte'), ({'target_type': 'message', 'target_id': 'abc', 'reason': 'spam'}, 'target_id', 'identifiant « abc »'), ({'target_type': 'message', 'target_id': 0, 'reason': 'spam'}, 'target_id', 'identifiant 0'), ({'target_type': 'message', 'reason': 'spam'}, 'target_id', 'identifiant absent'), ({'target_type': 'conversation', 'target_id': 1, 'reason': 'spam'}, 'target_type', 'type inconnu')):
    st, d, _ = call('POST', 'r_a', '/reports', body); ok(st == 422 and key in d['errors'], f'{why} : 422 sur « {key} »')
ok(cnt("SELECT COUNT(*) FROM reports WHERE reporter_id LIKE 'r\\_%'") == 0, 'les refus n\'écrivent rien')
st, d, _ = report('r_a', 'message', M[0]); ok(st == 422 and 'propre' in d['error'], 'son PROPRE message : 422')
ok(report('r_d', 'message', M[1])[0] == 404 and report('r_d', 'message', M[1])[1] == {'error': 'Contenu introuvable'}, 'un tiers qui n\'est pas dans la conversation : « Contenu introuvable » (404), sans rien révéler')
ok(report('r_a', 'message', 999999)[0] == 404, 'message inexistant : 404')
st, d, _ = report('r_a', 'message', M[1], 'harassment', 'Insultes répétées'); R1 = d['id']; ok(st == 201 and d['ok'] and isinstance(R1, int), 'message d\'un autre : 201 avec l\'identifiant du signalement')
st, d, _ = report('r_a', 'message', M[1], 'spam'); ok(st == 200 and d['already'] is True and d['id'] == R1 and cnt("SELECT COUNT(*) FROM reports WHERE target_type = 'message' AND target_id = " + str(M[1])) == 1, 'même signalant, même message, signalement en cours : « déjà signalé » (200), aucun doublon')
ok(report('r_b', 'message', M[0], 'spam')[0] == 201, 'l\'AUTRE membre peut aussi signaler un message d\'Anne (signalement distinct)')
call('POST', 'r_c', '/blocks', {'user_id': 'r_b', 'block': True}); ok(report('r_c', 'message', X, 'harassment')[0] == 201, 'après avoir BLOQUÉ l\'auteur : on peut encore signaler ce qu\'on a reçu'); call('POST', 'r_c', '/blocks', {'user_id': 'r_b', 'block': False})
st, d, _ = report('r_a', 'message', M[3], 'spam', 'texte'); ok(st == 201, 'un autre message du même auteur : nouveau signalement (le doublon est par message)')
sql(f"UPDATE conversation_members SET cleared_before_id = {M[5]} WHERE conversation_id = {C} AND user_id = 'r_a'"); ok(report('r_a', 'message', M[4], 'spam')[0] == 404 and report('r_a', 'message', M[7], 'spam')[0] == 201, 'après « supprimer pour moi » : un message d\'AVANT n\'est plus visible, donc pas signalable ; un message d\'APRÈS l\'est'); sql(f"UPDATE conversation_members SET cleared_before_id = 0 WHERE conversation_id = {C} AND user_id = 'r_a'")
call('DELETE', 'r_b', f'/conversations/{C}/messages/{M[9]}'); ok(report('r_a', 'message', M[9], 'spam')[0] == 404, 'un message déjà SUPPRIMÉ n\'est plus signalable')
sql(f"UPDATE conversation_members SET deleted_at = UTC_TIMESTAMP() WHERE conversation_id = {C2} AND user_id = 'r_c'"); ok(report('r_c', 'message', X, 'spam')[0] in (200, 404), 'conversation supprimée pour moi : traité sans erreur'); sql(f"UPDATE conversation_members SET deleted_at = NULL WHERE conversation_id = {C2}")

section("Copie de preuve : le message et un contexte limité")
setting('moderation_context_messages', 10); st, d, _ = report('r_b', 'message', M[10], 'harassment', 'ctx10'); R10 = d['id']; sn = detail(R10)[1]['snapshot']
ok(sn['type'] == 'message' and sn['conversation_id'] == C and {k: sn['message'][k] for k in ('id', 'author_id', 'author_name', 'body', 'edited')} == {'id': M[10], 'author_id': 'r_a', 'author_name': 'Anne A.', 'body': 'M11', 'edited': False}, 'copie : type « message », conversation, identifiant, auteur « Anne A. », texte « M11 », non modifié')
ids = [c['id'] for c in sn['context']]; ok(ids == M[5:16] and [c['reported'] for c in sn['context']].count(True) == 1 and sn['context'][5]['id'] == M[10] and sn['context'][5]['reported'], 'contexte de 10 : 5 messages avant, le message signalé (marqué), 5 après — dans l\'ordre')
ok(all(c['body'] == f'M{M.index(c["id"]) + 1:02d}' or c['deleted'] for c in sn['context']) and all({'id', 'author_id', 'author_name', 'body', 'deleted', 'created_at', 'reported'} == set(c) for c in sn['context']), 'chaque ligne du contexte : identifiant, auteur, texte, supprimé, date, « signalé »')
for n, exp_before, exp_after, why in ((4, 2, 2, 'pair'), (3, 2, 1, 'impair : le plus long côté avant'), (1, 1, 0, '1 seul'), (0, 0, 0, 'zéro : le message seul')):
    setting('moderation_context_messages', n); sql(f"DELETE FROM reports WHERE reporter_id = 'r_b' AND target_type = 'message' AND target_id = {M[12]}"); st, d, _ = report('r_b', 'message', M[12], 'spam'); s2 = detail(d['id'])[1]['snapshot']['context']
    ok([c['id'] for c in s2] == M[12 - exp_before:13 + exp_after], f'moderation_context_messages = {n} ({why}) : {exp_before} avant, le message, {exp_after} après')
setting('moderation_context_messages', 10); sdg = detail(R1)[1]['snapshot']['context']
ok([c['id'] for c in sdg] == M[0:7], 'au bord de la conversation (2e message) : 1 message avant (il n\'y en a pas plus), le message, 5 après — le contexte s\'arrête au début, sans erreur')
sql(f"UPDATE conversation_members SET cleared_before_id = {M[8]} WHERE conversation_id = {C} AND user_id = 'r_a'"); st, d, _ = report('r_a', 'message', M[11], 'spam', 'sans anciens'); sdc = detail(d['id'])[1]['snapshot']['context']
ok(min(c['id'] for c in sdc) > M[8], 'le contexte ne contient JAMAIS un message que le signalant a effacé de sa vue (supprimé pour moi)'); sql(f"UPDATE conversation_members SET cleared_before_id = 0 WHERE conversation_id = {C} AND user_id = 'r_a'")
ok(all(c['id'] in M for c in sn['context']) and X not in [c['id'] for c in sn['context']], 'aucun message d\'une AUTRE conversation')
ctx9 = detail(R10)[1]['snapshot']['context']; ok(any(c['id'] == M[9] and c['deleted'] and c['body'] is None for c in ctx9), 'un message supprimé AVANT le signalement figure dans le contexte comme « supprimé », sans texte')
call('PUT', 'r_a', f'/conversations/{C}/messages/{M[10]}', {'body': 'M11 réécrit'}); call('DELETE', 'r_a', f'/conversations/{C}/messages/{M[10]}')
ok(cnt(f"SELECT body IS NULL FROM messages WHERE id = {M[10]}") == 1 and detail(R10)[1]['snapshot']['message']['body'] == 'M11', 'l\'auteur modifie puis SUPPRIME son message : le contenu est purgé de la conversation, mais la copie du signalement garde le texte ORIGINAL')
ok(detail(R10)[1]['report']['target_state'] == 'deleted' and detail(R10)[1]['live_body'] is None, 'l\'état du message est « supprimé » ; aucune lecture en direct')

section("Lecture par la modération : uniquement via le signalement, toujours journalisée")
ok(call('GET', 'r_a', '/admin-reports')[0] == 403 and call('GET', 'r_a', f'/admin-reports/{R1}')[0] == 403 and call('POST', 'r_a', f'/admin-reports/{R1}/action', {'action': 'dismiss'})[0] == 403, 'un membre ordinaire : 403 partout dans la file de modération')
ok(call('GET', 'r_resp', '/admin-reports')[0] == 200 and call('GET', 'r_adm', '/admin-reports')[0] == 200, 'responsable et admin y accèdent')
n0 = cnt(f"SELECT COUNT(*) FROM moderation_audit WHERE report_id = {R1} AND action = 'report_view'"); st, d, _ = detail(R1, 'r_resp'); n1 = cnt(f"SELECT COUNT(*) FROM moderation_audit WHERE report_id = {R1} AND action = 'report_view'")
ok(st == 200 and n1 == n0 + 1 and sql(f"SELECT actor_id FROM moderation_audit WHERE report_id = {R1} AND action = 'report_view' ORDER BY id DESC LIMIT 1") == 'r_resp', 'CHAQUE lecture est journalisée (qui, quel signalement)'); detail(R1); ok(cnt(f"SELECT COUNT(*) FROM moderation_audit WHERE report_id = {R1} AND action = 'report_view'") == n1 + 1, 'y compris les lectures répétées')
ok(d['report']['target_type'] == 'message' and d['report']['reason'] == 'harassment' and d['report']['note'] == 'Insultes répétées' and d['report']['reporter']['name'] == 'Anne Adam' and d['report']['reported']['name'] == 'Bruno Blanc' and d['live_body'] is None and d['snapshot']['type'] == 'message' and d['report']['excerpt'] == d['snapshot']['message']['body'], 'fiche : signalant, signalé, motif, note, copie du message ; jamais de lecture en direct (live_body nul)')
ok(call('GET', 'r_adm', f'/conversations/{C}')[0] == 404 and call('GET', 'r_adm', f'/conversations/{C}/messages')[0] == 404 and call('GET', 'r_adm', '/conversations')[1]['items'] == [] and call('POST', 'r_adm', f'/conversations/{C}/messages', {'body': 'x'})[0] == 404, 'la modération ne peut PAS ouvrir la conversation par ailleurs : introuvable en lecture comme en écriture, liste vide (elle n\'en est pas membre)')
setting('moderation_context_messages', 10); st, d, _ = call('GET', 'r_adm', '/admin-reports?status=active&type=message'); ok(st == 200 and d['items'] and all(i['target_type'] == 'message' for i in d['items']) and all('snapshot' not in i and 'context' not in i for i in d['items']), 'liste filtrée par type « message » : seulement des messages, sans la copie ni le contexte (ils ne sont que dans la fiche)')
ok(call('GET', 'r_adm', '/admin-reports?type=message')[0] == 200 and call('GET', 'r_adm', '/admin-reports?type=member')[0] == 200 and call('GET', 'r_adm', '/admin-reports?type=bogus')[0] == 400, 'types acceptés : message, member ; type inconnu : 400')
r1 = [i for i in call('GET', 'r_adm', '/admin-reports?status=all&type=message&limit=50')[1]['items'] if i['id'] == R1][0]; ok(r1['target_state'] == 'visible' and r1['reported']['reports_received'] >= 3, 'dans la liste : état du message, nombre de signalements déjà reçus par ce membre')
st, d, _ = detail(R1); ok(any(x['id'] != R1 for x in d['history']) and all('snapshot' not in h for h in d['history']), 'historique des autres signalements reçus par ce membre (sans leur contenu)'); ok(d['same_target'] == [], 'aucun autre signalement sur CE message pour l\'instant')
sql(f"INSERT INTO conversation_members (conversation_id, user_id) VALUES ({C}, 'r_d')"); st, d2, _ = report('r_d', 'message', M[1], 'spam'); sql(f"DELETE FROM conversation_members WHERE conversation_id = {C} AND user_id = 'r_d'"); st, d3, _ = detail(R1); ok(any(x['id'] == d2['id'] for x in d3['same_target']), 'deux signalants sur le même message : chacun voit l\'autre dans « mêmes cibles »')

section("Actions de modération sur un message")
st, d, _ = act(R1, 'hide'); ok(st == 409 and 'applicable' in d['error'], '« masquer » ne s\'applique pas à un message privé (409)'); ok(act(R1, 'unhide')[0] == 409, '« démasquer » non plus')
ok(act(R1, 'inconnue')[0] == 400, 'action inconnue : 400')
sql("DELETE FROM notifications WHERE user_id LIKE 'r\\_%'"); st, d, _ = report('r_a', 'message', M[13], 'spam'); Rd = d['id']
sql(f"INSERT INTO reports (reporter_id, reported_user_id, target_type, target_id, post_id, reason, note, snapshot, status, created_at, updated_at) SELECT 'r_c', reported_user_id, target_type, target_id, post_id, reason, note, snapshot, 'open', created_at, updated_at FROM reports WHERE id = {Rd}"); Rd2 = int(sql("SELECT MAX(id) FROM reports WHERE reporter_id = 'r_c' AND target_type = 'message'")); st, d, _ = act(Rd, 'claim'); ok(st == 200 and d['report']['status'] == 'in_progress' and d['report']['handled_by']['name'] == 'Alice Admin', 'prendre en charge : « en cours », au nom du modérateur'); ok(act(Rd, 'claim', 'r_resp')[0] == 409, 'un autre modérateur ne peut pas le prendre')
st, d, _ = act(Rd, 'delete', note='Contenu injurieux'); ok(st == 200 and d['report']['status'] == 'treated' and d['report']['resolution'] == 'deleted', 'supprimer le message : signalement « traité », résolution « deleted »')
ok(cnt(f"SELECT deleted_at IS NOT NULL AND body IS NULL FROM messages WHERE id = {M[13]}") == 1, 'le contenu est PURGÉ de la base tout de suite (texte vide, date de suppression)')
for who in ('r_a', 'r_b'): mv = [m for m in call('GET', who, f'/conversations/{C}/messages?limit=100')[1]['items'] if m['id'] == M[13]]; ok(len(mv) == 1 and mv[0]['deleted'] is True and mv[0]['body'] is None, f'« Message supprimé » côté {who} : bulle sans texte, pour les DEUX membres')
ok(detail(Rd)[1]['snapshot']['message']['body'] == 'M14', 'la copie du signalement garde le texte')
ok(sql(f"SELECT status FROM reports WHERE id = {Rd2}") == 'treated' and sql(f"SELECT resolution FROM reports WHERE id = {Rd2}") == 'deleted', 'TOUS les signalements ouverts sur ce message sont clos du même coup')
nc, na = notifs('r_c'), notifs('r_a'); ok(len(nc) == 1 and nc[0]['kind'] == 'report_closed' and len(na) >= 1 and 'Contenu injurieux' not in json.dumps(nc), 'chaque signalant est prévenu du résultat, SANS le détail (ni la note du modérateur)'); ok(all(n['kind'] != 'warning' for n in notifs('r_b')), 'l\'auteur n\'est pas prévenu s\'il n\'y a pas de demande d\'avertissement')
au = sql(f"SELECT CONCAT(actor_id, '|', action) FROM moderation_audit WHERE report_id = {Rd} AND action = 'report_delete'"); ok(au == 'r_adm|report_delete', 'action journalisée : qui, quoi, sur quel signalement')
ok(act(Rd, 'delete')[0] == 409, 'refaire l\'action sur un signalement clos : 409')
st, d, _ = report('r_a', 'message', M[15], 'spam'); Rw = d['id']; sql("DELETE FROM notifications WHERE user_id LIKE 'r\\_%'"); st, d, _ = act(Rw, 'delete', warn=True, note='Merci de rester courtois')
nb = notifs('r_b'); ok(st == 200 and len(nb) == 1 and nb[0]['kind'] == 'warning' and nb[0].get('note') == 'Merci de rester courtois', 'supprimer AVEC avertissement : l\'auteur est prévenu, avec la note du modérateur')
st, d, _ = report('r_a', 'message', M[17], 'spam'); Rv = d['id']; call('DELETE', 'r_b', f'/conversations/{C}/messages/{M[17]}'); ok(act(Rv, 'delete')[0] == 409 and detail(Rv)[1]['snapshot']['message']['body'] == 'M18', 'supprimer un message que l\'auteur a déjà supprimé : « n\'existe plus » (409), la copie reste lisible')
st, d, _ = act(Rv, 'dismiss'); ok(st == 200 and d['report']['status'] == 'rejected' and d['report']['resolution'] == 'dismissed', 'rejeter : « rejeté », résolution « dismissed »')
st, d, _ = report('r_a', 'message', M[19], 'spam'); Rwarn = d['id']; sql("DELETE FROM notifications WHERE user_id LIKE 'r\\_%'"); st, d, _ = act(Rwarn, 'warn', note='Attention'); ok(st == 200 and d['report']['resolution'] == 'warned' and notifs('r_b')[0]['kind'] == 'warning' and notifs('r_a')[0]['kind'] == 'report_closed', 'avertir : l\'auteur reçoit un avertissement, le signalant le résultat')
st, d, _ = report('r_a', 'message', M[5], 'spam'); st, d, _ = act(d['id'], 'resolve'); ok(st == 200 and d['report']['status'] == 'treated' and d['report']['resolution'] == 'resolved', '« résolu » : traité sans autre effet')
ok(report('r_a', 'message', M[19], 'spam')[0] == 201, 'après la clôture, le même signalant peut signaler de nouveau (nouveau signalement)'); sql(f"UPDATE reports SET status = 'rejected', closed_at = UTC_TIMESTAMP() WHERE target_id = {M[19]} AND status = 'open'")

section("Suspendre la messagerie")
mx = send('r_b', C, 'Un message à supprimer plus tard')[1]['message']['id']; st, d, _ = report('r_a', 'message', M[19], 'harassment', 'Récidive'); Rs = d['id']
for body, why in (({}, 'sans durée'), ({'days': 0}, '0 jour'), ({'days': 366}, '366 jours'), ({'days': -3}, 'durée négative'), ({'days': '7'}, 'durée en texte'), ({'days': 7.5}, 'durée décimale')):
    st, d, _ = act(Rs, 'suspend_messaging', **body); ok(st == 422 and 'Durée' in d['error'], f'{why} : refusé (422), rien n\'est suspendu')
ok(sql("SELECT messaging_suspended_until IS NULL FROM users WHERE id = 'r_b'") == '1', 'rien n\'a été écrit par les refus')
sql("DELETE FROM notifications WHERE user_id LIKE 'r\\_%'"); st, d, _ = act(Rs, 'suspend_messaging', days=7, note='Harcèlement confirmé')
ok(st == 200 and d['report']['status'] == 'treated' and d['report']['resolution'] == 'suspended', 'suspendre 7 jours : signalement « traité », résolution « suspended »')
until = sql("SELECT UNIX_TIMESTAMP(messaging_suspended_until) - UNIX_TIMESTAMP(UTC_TIMESTAMP()) FROM users WHERE id = 'r_b'"); ok(6.9 * 86400 < float(until) <= 7 * 86400 + 5, f'fin de suspension : dans 7 jours ({float(until) / 86400:.2f} j)')
ok(notifs('r_b')[0]['kind'] == 'messaging_suspended' and notifs('r_b')[0].get('note') == 'Harcèlement confirmé' and notifs('r_a')[0]['kind'] == 'report_closed', 'le membre suspendu est prévenu (avec la note), le signalant reçoit le résultat sans détail')
reset_rate_limits(); st, d, _ = send('r_b', C, 'Je continue')
ok(st == 403 and d['error'] == 'Votre messagerie est suspendue' and 6.9 * 86400 < __import__('datetime').datetime.fromisoformat(d['suspended_until'].replace('Z', '+00:00')).timestamp() - __import__('time').time() <= 7 * 86400 + 5, 'écrire : 403 « Votre messagerie est suspendue », avec la date de fin (ISO UTC)')
ok(start('r_b', 'r_d', 'Nouvelle')[0] == 403 and cnt("SELECT COUNT(*) FROM conversations WHERE direct_key = 'r_b|r_d'") == 0, 'démarrer une conversation : 403, aucune conversation créée')
ok(call('GET', 'r_b', '/conversations')[0] == 200 and call('GET', 'r_b', f'/conversations/{C}/messages')[0] == 200 and call('POST', 'r_b', f'/conversations/{C}/read', {})[0] == 200, 'lire reste possible (liste, messages, marquer lu) : la suspension ne coupe pas la lecture')
ok(call('PUT', 'r_b', f'/conversations/{C}', {'muted': True})[0] == 200 and call('DELETE', 'r_b', f'/conversations/{C}/messages/{mx}')[0] == 200, 'régler une conversation et supprimer SON message restent possibles')
ok(send('r_a', C, 'Anne écrit à un membre suspendu')[0] == 201, 'les autres membres peuvent toujours lui écrire')
ok(call('GET', 'r_b', '/conversations/unread')[1]['suspended_until'] is not None and call('GET', 'r_a', '/conversations/unread')[1]['suspended_until'] is None, 'le compteur léger indique la suspension (pour afficher un bandeau) ; nul pour les autres')
ok(act(Rs, 'suspend_messaging', days=3)[0] == 409, 'suspendre sur un signalement DÉJÀ clos : 409 (il faut un signalement ouvert)')
sql("UPDATE users SET messaging_suspended_until = UTC_TIMESTAMP() - INTERVAL 1 MINUTE WHERE id = 'r_b'"); ok(send('r_b', C, 'Suspension terminée')[0] == 201 and call('GET', 'r_b', '/conversations/unread')[1]['suspended_until'] is None, 'la suspension se termine d\'elle-même à l\'échéance : on réécrit, plus de bandeau')
sql("UPDATE users SET messaging_suspended_until = UTC_TIMESTAMP() + INTERVAL 5 DAY WHERE id = 'r_b'"); sql("DELETE FROM notifications WHERE user_id = 'r_b'"); st, d, _ = act(Rs, 'unsuspend_messaging'); ok(st == 200 and sql("SELECT messaging_suspended_until IS NULL FROM users WHERE id = 'r_b'") == '1' and send('r_b', C, 'Levée à la main')[0] == 201, 'LEVER la suspension : possible même sur un signalement clos ; on réécrit tout de suite')
ok(notifs('r_b')[0]['kind'] == 'messaging_restored' and sql(f"SELECT COUNT(*) FROM moderation_audit WHERE report_id = {Rs} AND action = 'report_unsuspend_messaging'") == '1' and sql(f"SELECT COUNT(*) FROM moderation_audit WHERE report_id = {Rs} AND action = 'report_suspend_messaging'") == '1', 'le membre est prévenu, et la suspension comme sa levée sont journalisées')
sql("UPDATE users SET messaging_suspended_until = UTC_TIMESTAMP() + INTERVAL 5 DAY WHERE id = 'r_b'"); reset_rate_limits()
st, d, _ = start('r_d', 'r_b', 'Bonjour Bruno'); ok(st == 201, 'un membre suspendu reste joignable : on peut lui ÉCRIRE une demande')
pid = call('POST', 'r_b', '/posts', {'body': 'Un post', 'theme_id': int(sql("SELECT id FROM post_themes WHERE active = 1 AND system_key IS NULL ORDER BY id LIMIT 1")), 'visibility': 'all'}); ok(pid[0] == 201, 'la suspension de la messagerie ne coupe pas le fil (il peut toujours publier)')
sql("UPDATE users SET messaging_suspended_until = NULL WHERE id = 'r_b'"); pcid = pid[1]['post']['id']; st, d, _ = report('r_a', 'post', pcid, 'spam'); st, d, _ = act(d['id'], 'suspend_messaging', days=3); ok(st == 200 and sql("SELECT messaging_suspended_until IS NOT NULL FROM users WHERE id = 'r_b'") == '1', 'on peut aussi suspendre la messagerie depuis un signalement de PUBLICATION (même action)'); sql("UPDATE users SET messaging_suspended_until = NULL WHERE id = 'r_b'")

section("Signaler un membre")
for body, key, why in (({'reason': 'spam'}, 'target_id', 'identifiant absent'), ({'target_id': 5, 'reason': 'spam'}, 'target_id', 'identifiant qui n\'est pas du texte'), ({'target_id': '', 'reason': 'spam'}, 'target_id', 'identifiant vide'), ({'target_id': 'r_b'}, 'reason', 'motif absent'), ({'target_id': 'r_b', 'reason': 'other'}, 'note', '« autre » sans précision')):
    st, d, _ = call('POST', 'r_a', '/reports', {'target_type': 'member', **body}); ok(st == 422 and key in d['errors'], f'{why} : 422 sur « {key} »')
ok(report('r_a', 'member', 'r_a')[0] == 422, 'se signaler soi-même : 422'); ok(report('r_a', 'member', 'r_inconnu')[0] == 404, 'membre inconnu : 404'); ok(report('r_d', 'member', 'r_c')[0] == 404, 'un membre avec qui on ne partage AUCUNE conversation : 404 (pas de signalement « à l\'aveugle »)')
st, d, _ = report('r_a', 'member', 'r_b', 'impersonation', 'Se fait passer pour quelqu\'un'); RM = d['id']; ok(st == 201, 'membre avec qui on partage une conversation : 201')
st, d, _ = report('r_a', 'member', 'r_b', 'spam'); ok(st == 200 and d['already'] and d['id'] == RM, 'doublon en cours : « déjà signalé »')
sd = detail(RM)[1]; ok(sd['snapshot'] == {'type': 'member', 'member': {'id': 'r_b', 'name': 'Bruno B.'}} and sd['report']['target_type'] == 'member' and sd['report']['reported']['id'] == 'r_b' and sd['live_body'] is None and sd['report']['excerpt'] == '', 'copie : seulement l\'identité du membre — AUCUN message n\'est joint, aucune lecture de conversation')
ok(report('r_c', 'member', 'r_b', 'spam')[0] == 201 and call('GET', 'r_adm', f'/admin-reports/{RM}')[1]['same_target'][0]['reason'] == 'spam', 'un autre signalant sur le même membre : listé dans « mêmes cibles »')
ok(all(s['id'] != R1 for s in detail(RM)[1]['same_target']), 'et jamais les signalements de MESSAGES de ce membre (la cible est le membre)')
ok(act(RM, 'delete')[0] == 409 and act(RM, 'hide')[0] == 409, 'supprimer / masquer un membre : non applicable (409)')
sql("DELETE FROM notifications WHERE user_id LIKE 'r\\_%'"); st, d, _ = act(RM, 'warn', note='Merci de corriger votre profil'); ok(st == 200 and d['report']['resolution'] == 'warned' and notifs('r_b')[0]['kind'] == 'warning', 'avertir un membre : clos, avertissement envoyé')
st, d, _ = report('r_a', 'member', 'r_b', 'harassment', 'x'); st, d, _ = act(d['id'], 'suspend_messaging', days=30); ok(st == 200 and float(sql("SELECT UNIX_TIMESTAMP(messaging_suspended_until) - UNIX_TIMESTAMP(UTC_TIMESTAMP()) FROM users WHERE id = 'r_b'")) > 29 * 86400, 'suspendre la messagerie 30 jours depuis un signalement de membre'); sql("UPDATE users SET messaging_suspended_until = NULL WHERE id = 'r_b'")
st, d, _ = report('r_a', 'member', 'r_b', 'spam', 'y'); st, d, _ = act(d['id'], 'dismiss'); ok(d['report']['status'] == 'rejected', 'rejeter un signalement de membre')
ok(detail(RM)[1]['report']['reported']['reports_received'] >= 5, 'le compteur de signalements reçus compte messages ET membres')

section("Conservation et suppression des copies")
st, d, _ = report('r_c', 'message', X, 'spam', 'ancien'); Ro = d['id']; sql(f"UPDATE reports SET status = 'treated', resolution = 'resolved', closed_at = UTC_TIMESTAMP() - INTERVAL 7 MONTH WHERE id = {Ro}")
st, d, _ = report('r_c', 'member', 'r_b', 'spam', 'récent'); Rr = d['id']; sql(f"UPDATE reports SET status = 'treated', resolution = 'resolved', closed_at = UTC_TIMESTAMP() - INTERVAL 2 MONTH WHERE id = {Rr}")
st, d, _ = report('r_c', 'message', X, 'harassment', 'ouvert depuis longtemps'); Rop = d['id']; sql(f"UPDATE reports SET created_at = UTC_TIMESTAMP() - INTERVAL 12 MONTH WHERE id = {Rop}")
php('moderation_purge(db());'); ok(cnt(f"SELECT COUNT(*) FROM reports WHERE id = {Ro}") == 0, 'copie d\'un signalement clos depuis 7 mois (conservation : 6) : EFFACÉE avec son contenu')
ok(cnt(f"SELECT COUNT(*) FROM reports WHERE id = {Rr}") == 1 and cnt(f"SELECT COUNT(*) FROM reports WHERE id = {Rop}") == 1, 'clos depuis 2 mois : conservé ; encore OUVERT (même vieux d\'un an) : jamais effacé')
setting('report_copy_retention_months', 1); php('moderation_purge(db());'); ok(cnt(f"SELECT COUNT(*) FROM reports WHERE id = {Rr}") == 0 and cnt(f"SELECT COUNT(*) FROM reports WHERE id = {Rop}") == 1, 'réglage à 1 mois : le signalement clos depuis 2 mois part aussi, appliqué tout de suite'); unset('report_copy_retention_months')

wipe(); sql("DELETE FROM users WHERE id LIKE 'r\\_%'"); [unset(k) for k in ('moderation_context_messages', 'messages_per_minute', 'report_copy_retention_months')]; summary()
