#!/usr/bin/env python3
"""Paramètres de la messagerie modifiables depuis /control (CDC § 9.2 et § 13) : endpoints admin-messaging-settings et messaging-config."""
import json, os, re, subprocess, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from lib import ROOT, call, mk_user, ok, reset_themes, sql, summary

# ── Ce que le cahier des charges (§ 13) impose : clé → (type, min, max, défaut) ─────────────────────────────────────
INTS = {'video_max_mb': (5, 500, 50), 'video_max_seconds': (10, 300, 60), 'images_max_per_message': (1, 20, 10), 'images_max_per_post': (1, 20, 10),
        'storage_quota_mb_per_member': (0, 10000, 500), 'new_account_days': (0, 90, 7), 'message_requests_per_day': (1, 100, 5), 'messages_per_minute': (1, 60, 20),
        'invite_max_per_send': (1, 200, 50), 'invite_max_per_day': (1, 1000, 100), 'unread_email_delay_min': (5, 1440, 15), 'unread_email_hour': (0, 23, 9),
        'group_max_members': (3, 200, 50), 'activity_window_days': (1, 60, 7), 'moderation_context_messages': (0, 30, 10), 'conversation_purge_days': (1, 365, 30),
        'report_copy_retention_months': (1, 60, 6), 'audit_retention_months': (6, 60, 12), 'notifications_retention_days': (7, 365, 90),
        'post_max_chars': (100, 10000, 2000), 'comment_max_chars': (50, 5000, 1000), 'mentions_max_per_content': (1, 20, 10), 'message_max_chars': (100, 10000, 2000), 'auto_posts_daily_cap': (1, 20, 3)}
BOOLS = {'unread_email_enabled': True, 'muted_still_notifies_mentions': True, 'link_preview_enabled': False, 'auto_posts_enabled': True, 'auto_posts_action_started': True, 'auto_posts_action_finished': True, 'auto_posts_parcours_started': True, 'auto_posts_parcours_finished': True}
TEXTS = {'cheer_quick_1_fr': (60, 'Bravo !'), 'cheer_quick_1_en': (60, 'Well done!'), 'cheer_quick_2_fr': (60, 'Courage !'), 'cheer_quick_2_en': (60, 'Keep going!'), 'cheer_quick_3_fr': (60, 'Hâte de voir la suite'), 'cheer_quick_3_en': (60, "Can't wait to see what's next"), 'cheer_quick_4_fr': (60, ''), 'cheer_quick_4_en': (60, '')}
ENUMS = {'network_definition': (['following', 'mutual'], 'following'), 'badge_kind': (['specialty', 'level', 'status'], 'specialty'), 'unread_email_weekday': (['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'], 'monday')}
PUBLIC = {'video_max_mb', 'video_max_seconds', 'images_max_per_message', 'images_max_per_post', 'link_preview_enabled', 'group_max_members', 'network_definition', 'badge_kind', 'post_max_chars', 'comment_max_chars', 'message_max_chars'} | set(TEXTS)
SEED = ['Leads', 'Conversion', 'Offre', 'Modèles commerciaux', 'Image de marque', 'Quotidien']

def reset():
    sql("DELETE FROM messaging_settings_log; DELETE FROM messaging_settings; DELETE FROM users WHERE id LIKE 'm\\_%'")
    reset_themes()                                                  # les 6 thèmes amorcés, puis le thème réservé « Avancées »
def get(who='m_admin'):
    st, d, _ = call('GET', who, '/admin-messaging-settings'); assert st == 200, (st, d); return d
def by_key(d): return {s['key']: s for s in d['settings']}
def put(changes, who='m_admin'): return call('PUT', who, '/admin-messaging-settings', {'changes': changes})
def nlog(): return int(sql("SELECT COUNT(*) FROM messaging_settings_log"))
def logs(key): return [tuple(r.split('\t')) for r in sql(f"SELECT action, COALESCE(old_value,'NULL'), COALESCE(new_value,'NULL'), COALESCE(changed_by,'') FROM messaging_settings_log WHERE setting_key='{key}' ORDER BY id").split('\n') if r]
def php(code):
    return subprocess.run(['php', '-r', '$_SERVER["HTTP_HOST"]=""; ob_start(); require ' + json.dumps(ROOT + '/api/_messaging_settings.php') + '; ob_end_clean(); ' + code], capture_output=True, text=True)

reset(); mk_user('m_admin', 'admin', 'Alice', 'Admin'); mk_user('m_resp', 'responsable'); mk_user('m_user', 'prospect')

print("\n■ Conformité au cahier des charges (§ 13)")
d = get(); S = by_key(d)
ok(set(S) == set(INTS) | set(BOOLS) | set(ENUMS) | set(TEXTS) and len(S) == 43, f'les 43 paramètres (24 du cahier des charges § 13 + longueurs et mentions + 6 des publications automatiques + 8 messages rapides « Encourager ») existent, ni plus ni moins ({len(S)})')
bad = [k for k, (lo, hi, df) in INTS.items() if (S[k]['type'], S[k]['min'], S[k]['max'], S[k]['default']) != ('int', lo, hi, df)]
ok(not bad, '24 entiers : type, bornes et valeur par défaut identiques au cahier des charges' + (f' — écarts : {bad}' if bad else ''))
bad = [k for k, (mx, df) in TEXTS.items() if (S[k]['type'], S[k]['max'], S[k]['default']) != ('text', mx, df)]
ok(not bad, '8 textes (messages rapides « Encourager », 3 par défaut, en français et en anglais, un 4e vide) : type, longueur maximale 60 et valeur par défaut' + (f' — écarts : {bad}' if bad else ''))
bad = [k for k, df in BOOLS.items() if (S[k]['type'], S[k]['default']) != ('bool', df)] + [k for k, (vals, df) in ENUMS.items() if (S[k]['type'], S[k]['values'], S[k]['default']) != ('enum', vals, df)]
ok(not bad, '8 bascules et 3 choix : valeurs par défaut et choix possibles identiques' + (f' — écarts : {bad}' if bad else ''))
ok(all(s['value'] == s['default'] and s['overridden'] is False and s['updated_by'] is None for s in S.values()), 'sans rien en base : la valeur par défaut s\'applique partout, rien n\'est « modifié »')
ok(all('public' not in s for s in S.values()) and set(d) == {'settings', 'themes', 'can_edit'}, 'la réponse ne divulgue pas le drapeau interne « public »')
ok([t['label_fr'] for t in d['themes']] == SEED + ['Avancées'] and all(t['active'] for t in d['themes']) and d['themes'][2]['label_en'] == 'Offer' and [t['system'] for t in d['themes']] == [False] * len(SEED) + [True], 'thèmes de publication amorcés : les 6 domaines Uluana puis « Avancées » (thème réservé aux publications automatiques, marqué system), actifs, avec libellé anglais')
ok(len(open(ROOT + '/api/.htaccess', encoding='utf-8').read().split('admin-messaging-settings')) >= 3 and re.search(r'RewriteRule \^messaging-config\$\s+messaging-config\.php', open(ROOT + '/api/.htaccess', encoding='utf-8').read()), 'api/.htaccess : une règle par endpoint (admin-messaging-settings, son sous-chemin, messaging-config)')

print("\n■ Rôles")
ok(call('GET', None, '/admin-messaging-settings')[0] == 401, 'visiteur : 401')
ok(call('GET', 'm_user', '/admin-messaging-settings')[0] == 403, 'membre (prospect) : 403')
ok(call('GET', 'm_resp', '/admin-messaging-settings')[0] == 200 and get('m_resp')['can_edit'] is False and get('m_admin')['can_edit'] is True, 'responsable : peut CONSULTER (can_edit = false) ; admin : can_edit = true')
n0 = nlog()
ok(put({'video_max_mb': 80}, 'm_resp')[0] == 403 and call('POST', 'm_resp', '/admin-messaging-settings/themes', {'label_fr': 'X'})[0] == 403 and call('PUT', 'm_resp', '/admin-messaging-settings/themes/order', {'ids': []})[0] == 403, 'responsable : modifier un paramètre, créer ou ordonner un thème → 403')
ok(get()['settings'] and by_key(get())['video_max_mb']['value'] == 50 and nlog() == n0, '… et rien n\'a été écrit ni journalisé')

print("\n■ Modifier")
st, r, _ = put({'video_max_mb': 80, 'unread_email_enabled': False, 'badge_kind': 'level'})
S = by_key(r)
ok(st == 200 and sorted(r['changed']) == ['badge_kind', 'unread_email_enabled', 'video_max_mb'], 'admin : 3 paramètres modifiés d\'un coup (entier, bascule, choix)')
ok(S['video_max_mb']['value'] == 80 and S['unread_email_enabled']['value'] is False and S['badge_kind']['value'] == 'level' and all(S[k]['overridden'] for k in ('video_max_mb', 'unread_email_enabled', 'badge_kind')) and S['video_max_seconds']['overridden'] is False, 'la réponse et un nouveau GET reflètent les valeurs ; seuls ces 3 sont « modifiés »')
ok(S['video_max_mb']['updated_by'] == 'm_admin' and S['video_max_mb']['updated_by_name'] == 'Alice Admin' and S['video_max_mb']['updated_at'], 'qui et quand : identifiant, nom lisible et date')
ok(logs('video_max_mb') == [('set', '50', '80', 'm_admin')] and logs('unread_email_enabled') == [('set', 'true', 'false', 'm_admin')] and logs('badge_kind') == [('set', '"specialty"', '"level"', 'm_admin')], 'journal : ancienne valeur, nouvelle valeur, auteur, pour chacun')
n0 = nlog(); st, r, _ = put({'video_max_mb': 80, 'badge_kind': 'level'})
ok(st == 200 and r['changed'] == [] and nlog() == n0, 'même valeur renvoyée : rien ne change, rien n\'est journalisé')
st, r, _ = put({'video_max_seconds': '120', 'images_max_per_post': 12.0})
ok(st == 200 and by_key(r)['video_max_seconds']['value'] == 120 and by_key(r)['images_max_per_post']['value'] == 12 and isinstance(by_key(r)['video_max_seconds']['value'], int), 'une chaîne de chiffres ("120") et un décimal entier (12.0) sont acceptés et enregistrés comme ENTIERS')
st, r, _ = put({'storage_quota_mb_per_member': 0, 'muted_still_notifies_mentions': 0})
ok(st == 200 and by_key(r)['storage_quota_mb_per_member']['value'] == 0 and by_key(r)['muted_still_notifies_mentions']['value'] is False, 'bornes incluses (0 = illimité) ; 0 / 1 acceptés pour une bascule')

print("\n■ Validation côté serveur")
n0 = nlog(); before = by_key(get())
cases = {'video_max_mb': [4, 501, 50.5, 'abc', True, [], {}, '5 0'], 'group_max_members': [2, 201, -1], 'unread_email_enabled': ['yes', 2, 'true'], 'badge_kind': ['inconnu', 5, ''], 'network_definition': ['everyone']}
for k, vals in cases.items():
    for v in vals:
        st, r, _ = put({k: v}); ok(st == 422 and k in r.get('errors', {}), f'{k} = {json.dumps(v)} → refusé (422) : « {r.get("errors", {}).get(k)} »')
st, r, _ = put({'nimporte_quoi': 1}); ok(st == 422 and r['errors'] == {'nimporte_quoi': 'Paramètre inconnu'}, 'clé inconnue → 422 « Paramètre inconnu »')
ok(by_key(get()) == before and nlog() == n0, 'après tous ces refus : aucune valeur modifiée, aucune ligne de journal')
st, r, _ = put({'video_max_seconds': 200, 'video_max_mb': 4})
ok(st == 422 and list(r['errors']) == ['video_max_mb'] and by_key(get())['video_max_seconds']['value'] == 120 and nlog() == n0, 'ATOMIQUE : une valeur valide + une invalide → rien n\'est enregistré (la valeur valide non plus)')
ok(call('PUT', 'm_admin', '/admin-messaging-settings', raw=b'{pas du json')[0] == 400 and put({})[0] == 400 and call('PUT', 'm_admin', '/admin-messaging-settings', {})[0] == 400 and put({f'k{i}': 1 for i in range(61)})[0] == 400, 'corps invalide, « changes » absent ou vide, plus de 60 paramètres → 400')

print("\n■ Revenir à la valeur par défaut")
st, r, _ = put({'video_max_mb': None})
ok(st == 200 and r['changed'] == ['video_max_mb'] and by_key(r)['video_max_mb']['value'] == 50 and by_key(r)['video_max_mb']['overridden'] is False, 'null = valeur par défaut : la ligne est supprimée, le paramètre n\'est plus « modifié »')
ok(logs('video_max_mb')[-1] == ('reset', '80', '50', 'm_admin') and int(sql("SELECT COUNT(*) FROM messaging_settings WHERE setting_key='video_max_mb'")) == 0, 'journal : action « reset » (80 → 50)')
n0 = nlog(); st, r, _ = put({'video_max_mb': None, 'new_account_days': None}); ok(st == 200 and r['changed'] == [] and nlog() == n0, 'réinitialiser ce qui est déjà par défaut : rien à faire, rien de journalisé')
ok(put({'inconnu': None})[0] == 422, 'réinitialiser une clé inconnue → 422')

print("\n■ Valeur enregistrée devenue invalide (modifiée à la main en base)")
sql("INSERT INTO messaging_settings (setting_key, setting_value) VALUES ('badge_kind', '\"zzz\"') ON DUPLICATE KEY UPDATE setting_value='\"zzz\"'; INSERT INTO messaging_settings (setting_key, setting_value) VALUES ('video_max_mb', '99999') ON DUPLICATE KEY UPDATE setting_value='99999'")
S = by_key(get())
ok(S['badge_kind']['value'] == 'specialty' and S['badge_kind']['overridden'] is False and S['video_max_mb']['value'] == 50 and S['video_max_mb']['overridden'] is False, 'valeur invalide ou hors bornes en base : le défaut s\'applique, aucune erreur')
sql("DELETE FROM messaging_settings WHERE setting_key IN ('badge_kind','video_max_mb')")

print("\n■ Configuration publique pour l'interface")
put({'video_max_mb': 120, 'badge_kind': 'status', 'new_account_days': 30, 'audit_retention_months': 24})
st, r, h = call('GET', 'm_user', '/messaging-config')
ok(st == 200 and set(r['config']) == PUBLIC, 'un membre lit exactement les 10 paramètres utiles à l\'interface — ni anti-abus, ni conservation, ni quota')
ok(r['config']['video_max_mb'] == 120 and r['config']['badge_kind'] == 'status' and 'new_account_days' not in r['config'] and 'audit_retention_months' not in r['config'], 'les valeurs modifiées depuis /control sont visibles tout de suite ; les réglages internes restent invisibles')
ok(h.get('Cache-Control') == 'private, max-age=60', 'mise en cache privée de 60 s (jamais partagée)')
ok(call('GET', None, '/messaging-config')[0] == 401 and call('POST', 'm_user', '/messaging-config', {})[0] == 405, 'visiteur : 401 ; écriture : 405')
ok(call('GET', 'm_admin', '/messaging-config')[0] == 200, 'un admin la lit aussi')
t = [x for x in get()['themes'] if x['label_fr'] == 'Offre'][0]; call('PUT', 'm_admin', f'/admin-messaging-settings/themes/{t["id"]}', {'active': False})
ok('Offre' not in [x['label_fr'] for x in call('GET', 'm_user', '/messaging-config')[1]['themes']] and 'Offre' in [x['label_fr'] for x in get()['themes']], 'un thème désactivé disparaît de la liste proposée aux membres, mais reste dans /control')

print("\n■ Utilisation par les autres modules")
r = php('echo json_encode([messaging_setting(db(), "video_max_mb"), messaging_setting(db(), "badge_kind"), messaging_setting(db(), "unread_email_enabled")]);')
ok(json.loads(r.stdout or '[]') == [120, 'status', False], 'messaging_setting($pdo, clé) renvoie la valeur effective (celle enregistrée)')
r = php('try { messaging_setting(db(), "cle_inconnue"); echo "pas d erreur"; } catch (InvalidArgumentException $e) { echo "erreur : " . $e->getMessage(); }')
ok(r.stdout.startswith('erreur') and 'cle_inconnue' in r.stdout, 'une clé inconnue est une erreur de programmation (exception), jamais une valeur par défaut silencieuse')

print("\n■ Thèmes de publication")
st, r, _ = call('POST', 'm_admin', '/admin-messaging-settings/themes', {'label_fr': '  Photographie  '})
ok(st == 201 and r['id'] > 0, 'créer un thème : 201')
new = [x for x in r['themes'] if x['id'] == r['id']][0]
ok(new['label_fr'] == 'Photographie' and new['label_en'] == 'Photographie' and new['active'] is True and new['sort_order'] == max(x['sort_order'] for x in r['themes']), 'libellé nettoyé ; sans libellé anglais, le français sert ; actif ; placé en dernier')
for body, why in (({'label_fr': ''}, 'vide'), ({'label_fr': '   '}, 'espaces'), ({'label_fr': 'x' * 81}, '81 caractères'), ({'label_fr': 'ok', 'label_en': 'y' * 81}, 'anglais trop long'), ({'label_fr': 'photographie'}, 'doublon (casse)'), ({}, 'sans libellé'), ({'label_fr': ['a']}, 'pas du texte')):
    st, r, _ = call('POST', 'm_admin', '/admin-messaging-settings/themes', body); ok(st == 422, f'créer un thème refusé : {why}')
tid = new['id']
st, r, _ = call('PUT', 'm_admin', f'/admin-messaging-settings/themes/{tid}', {'label_fr': 'Photo', 'label_en': 'Photography'})
t = [x for x in r['themes'] if x['id'] == tid][0]; ok(st == 200 and (t['label_fr'], t['label_en']) == ('Photo', 'Photography'), 'renommer (français et anglais)')
st, r, _ = call('PUT', 'm_admin', f'/admin-messaging-settings/themes/{tid}', {'active': False}); ok(st == 200 and [x for x in r['themes'] if x['id'] == tid][0]['active'] is False, 'désactiver')
st, r, _ = call('PUT', 'm_admin', f'/admin-messaging-settings/themes/{tid}', {'active': True}); ok([x for x in r['themes'] if x['id'] == tid][0]['active'] is True, 'réactiver')
ok(call('PUT', 'm_admin', f'/admin-messaging-settings/themes/{tid}', {})[0] == 422 and call('PUT', 'm_admin', f'/admin-messaging-settings/themes/{tid}', {'active': 'oui'})[0] == 422 and call('PUT', 'm_admin', f'/admin-messaging-settings/themes/{tid}', {'label_fr': 'Leads'})[0] == 422 and call('PUT', 'm_admin', '/admin-messaging-settings/themes/999999', {'active': True})[0] == 404, 'rien à modifier, valeur invalide, doublon → 422 ; thème inconnu → 404')
ok(call('PUT', 'm_admin', f'/admin-messaging-settings/themes/{tid}', {'label_fr': 'Photo'})[0] == 200, 'garder son propre libellé n\'est pas un doublon')
ids = [x['id'] for x in get()['themes']]; rev = list(reversed(ids))
st, r, _ = call('PUT', 'm_admin', '/admin-messaging-settings/themes/order', {'ids': rev}); ok(st == 200 and [x['id'] for x in r['themes']] == rev, 'ordonner : le nouvel ordre est celui de la liste')
for body, why in (({'ids': ids[:-1]}, 'un thème manque'), ({'ids': ids + [ids[0]]}, 'doublon'), ({'ids': ids[:-1] + [999999]}, 'identifiant inconnu'), ({'ids': 'abc'}, 'pas une liste'), ({}, 'sans ids')):
    ok(call('PUT', 'm_admin', '/admin-messaging-settings/themes/order', body)[0] == 422, f'ordre refusé : {why}')
ok([x['id'] for x in get()['themes']] == rev, 'après les refus, l\'ordre est inchangé')
ok(call('DELETE', 'm_admin', f'/admin-messaging-settings/themes/{tid}')[0] == 404, 'pas de suppression de thème (on désactive : des publications y feront référence)')
ok([a for a, *_ in logs(f'theme:{tid}')] == ['theme_create', 'theme_update', 'theme_update', 'theme_update', 'theme_update'] and logs('themes')[-1][0] == 'theme_order', 'journal : création, modifications et changement d\'ordre des thèmes')
cnt = int(sql("SELECT COUNT(*) FROM post_themes"))
sql("INSERT INTO post_themes (label_fr, label_en, sort_order) VALUES " + ','.join(f"('Plein {i}','Full {i}',{100 + i})" for i in range(50 - cnt)))
ok(call('POST', 'm_admin', '/admin-messaging-settings/themes', {'label_fr': 'Un de trop'})[0] == 422, '50 thèmes au plus')
ok(call('GET', 'm_admin', '/admin-messaging-settings/inconnu')[0] == 404, 'route inconnue → 404')

sql("DELETE FROM users WHERE id LIKE 'm\\_%'")
summary()
