import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { LucideAngularModule, Save, ArrowLeft, Dices, Plus, Trash2, ChevronDown, ChevronRight, Upload } from 'lucide-angular';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { GameSystemService } from '../../services/game-system.service';
import { TemplateField } from '../../services/template.model';
import { TemplateFieldsEditorComponent } from '../../shared/template-fields-editor/template-fields-editor.component';
/**
* Éditeur plein écran d'un GameSystem. Rôle double création/édition :
* - `/game-systems/create` → POST au submit
* - `/game-systems/:id/edit` → PUT au submit
*
* UI par sections : au lieu d'un gros textarea de markdown, on présente
* une liste de cartes repliables (une par section H2). Au load on parse
* le markdown existant, au submit on le reconstruit. Les noms suggérés
* (Combat, Classes…) guident sans imposer — c'est le même mapping que
* le backend utilise pour filtrer les sections à injecter dans l'IA.
*/
interface RuleSection {
title: string;
content: string;
collapsed: boolean;
}
/**
* Sections suggérées : mécaniques de jeu uniquement. Lore/factions/PNJ
* appartiennent au module Lore de LoreMind (décrit l'univers de l'utilisateur),
* pas au GameSystem (décrit les règles). Séparation volontaire.
*/
const SUGGESTED_SECTIONS = [
'Combat', 'Classes', 'Stats', 'Magie', 'Monstres', 'Progression'
];
/** Suggestions de champs pour la fiche PJ — generiques (extension par template). */
const CHARACTER_FIELD_SUGGESTIONS = ['Histoire', 'Personnalite', 'Apparence', 'Notes'];
/** Suggestions de champs pour la fiche PNJ — focus sur les besoins MJ. */
const NPC_FIELD_SUGGESTIONS = ['Motivation', 'Apparence', 'Faction', 'Notes MJ'];
/** Suggestions de champs pour la fiche ENNEMI — stats de combat. */
const ENEMY_FIELD_SUGGESTIONS = ['Stats', 'Attaques', 'Capacités', 'Faiblesses', 'Butin', 'Tactique'];
@Component({
selector: 'app-game-system-edit',
imports: [FormsModule, LucideAngularModule, TranslatePipe, TemplateFieldsEditorComponent],
templateUrl: './game-system-edit.component.html',
styleUrls: ['./game-system-edit.component.scss']
})
export class GameSystemEditComponent implements OnInit {
readonly Save = Save;
readonly ArrowLeft = ArrowLeft;
readonly Dices = Dices;
readonly Plus = Plus;
readonly Trash2 = Trash2;
readonly ChevronDown = ChevronDown;
readonly ChevronRight = ChevronRight;
readonly Upload = Upload;
id: string | null = null;
/** Import PDF en cours (appel LLM long) → désactive le bouton + spinner. */
importing = false;
/** Message de succès post-import (sections ajoutées, pages, OCR). */
importNote: string | null = null;
/** Message d'erreur d'import (Brain injoignable, PDF illisible…). */
importError: string | null = null;
/** Libellé de l'étape courante (« Extraction… », « Analyse… (3/12) »). */
importPhase = '';
/** Avancement de la structuration ; null pendant l'extraction (total inconnu). */
importProgress: { current: number; total: number } | null = null;
/** Titres de sections trouvés au fil de l'eau (affichage live). */
importFound: string[] = [];
/**
* Dernier message de statut du flux (fournisseur saturé → retry, morceau
* re-découpé/ignoré…). Effacé à chaque progression : il explique l'ATTENTE
* en cours, pas l'historique.
*/
importStatus: string | null = null;
name = '';
description = '';
author = '';
sections: RuleSection[] = [];
characterTemplate: TemplateField[] = [];
npcTemplate: TemplateField[] = [];
enemyTemplate: TemplateField[] = [];
/** Type d'acteur Foundry (posé par l'import de structure). */
foundryActorType: string | null = null;
/** Import de structure Foundry en cours. */
importingStructure = false;
importStructureError: string | null = null;
readonly suggestedSections = SUGGESTED_SECTIONS;
readonly characterFieldSuggestions = CHARACTER_FIELD_SUGGESTIONS;
readonly npcFieldSuggestions = NPC_FIELD_SUGGESTIONS;
readonly enemyFieldSuggestions = ENEMY_FIELD_SUGGESTIONS;
constructor(
private route: ActivatedRoute,
private router: Router,
private service: GameSystemService,
private translate: TranslateService
) {}
ngOnInit(): void {
this.id = this.route.snapshot.paramMap.get('id');
if (this.id) {
this.service.getById(this.id).subscribe({
next: (gs) => {
this.name = gs.name;
this.description = gs.description ?? '';
this.author = gs.author ?? '';
this.sections = this.parseMarkdown(gs.rulesMarkdown ?? '');
this.characterTemplate = gs.characterTemplate ? [...gs.characterTemplate] : [];
this.npcTemplate = gs.npcTemplate ? [...gs.npcTemplate] : [];
this.enemyTemplate = gs.enemyTemplate ? [...gs.enemyTemplate] : [];
this.foundryActorType = gs.foundryActorType ?? null;
},
error: () => this.back()
});
}
}
/** Noms de section déjà utilisés — pour désactiver les suggestions en doublon. */
isSectionUsed(title: string): boolean {
const lower = title.toLowerCase();
return this.sections.some(s => s.title.toLowerCase() === lower);
}
/** Ajoute une section avec un nom suggéré. */
addSuggested(title: string): void {
if (this.isSectionUsed(title)) return;
this.sections.push({ title, content: '', collapsed: false });
}
/** Ajoute une section vierge (titre à remplir). */
addBlank(): void {
this.sections.push({ title: '', content: '', collapsed: false });
}
// --- Import d'un PDF de règles -------------------------------------------
/** Déclenché par le caché : lance l'import du PDF choisi. */
onPdfSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
// Reset de la valeur : re-sélectionner le même fichier doit re-déclencher.
input.value = '';
if (file) this.importPdf(file);
}
private importPdf(file: File): void {
this.importing = true;
this.importNote = null;
this.importError = null;
this.importPhase = this.translate.instant('gameSystemEdit.importExtracting');
this.importProgress = null;
this.importFound = [];
this.importStatus = null;
this.service.importRulesStream(file).subscribe({
next: (ev) => {
if (ev.type === 'progress') {
// Un morceau vient d'aboutir : le message d'attente est obsolète.
this.importStatus = null;
if (ev.total === 0) {
// Phase d'extraction (total encore inconnu).
this.importPhase = this.translate.instant('gameSystemEdit.importExtracting');
this.importProgress = null;
} else {
this.importPhase = this.translate.instant('gameSystemEdit.importAnalyzing', { current: ev.current, total: ev.total });
this.importProgress = { current: ev.current, total: ev.total };
for (const t of ev.newSectionTitles) {
if (!this.importFound.includes(t)) this.importFound.push(t);
}
}
} else if (ev.type === 'status') {
this.importStatus = ev.message;
} else if (ev.type === 'done') {
this.finishImport(ev.sections, ev.pageCount, ev.ocrPageCount);
}
},
error: (err: Error) => {
this.resetImportProgress();
this.importError = err?.message
? this.translate.instant('gameSystemEdit.importFailedReason', { reason: err.message })
: this.translate.instant('gameSystemEdit.importFailed');
}
});
}
private finishImport(sections: Record, pageCount: number, ocrPageCount: number): void {
this.resetImportProgress();
const added = this.mergeImportedSections(sections);
if (added === 0) {
this.importError = this.translate.instant('gameSystemEdit.importNoRules');
return;
}
const ocr = ocrPageCount > 0
? this.translate.instant('gameSystemEdit.importOcrSuffix', { count: ocrPageCount })
: '';
this.importNote = this.translate.instant('gameSystemEdit.importNote', {
added,
pages: pageCount,
ocr
});
}
private resetImportProgress(): void {
this.importing = false;
this.importPhase = '';
this.importProgress = null;
}
/**
* Fusionne les sections proposées dans l'éditeur. Section de même titre
* (insensible à la casse) → contenu ajouté à la suite ; sinon nouvelle carte.
* Retourne le nombre de sections effectivement intégrées.
*/
private mergeImportedSections(sections: Record): number {
let count = 0;
for (const [rawTitle, rawContent] of Object.entries(sections ?? {})) {
const title = (rawTitle ?? '').trim();
const content = (rawContent ?? '').trim();
if (!title || !content) continue;
const existing = this.sections.find(
s => s.title.trim().toLowerCase() === title.toLowerCase()
);
if (existing) {
existing.content = `${existing.content.trim()}\n\n${content}`.trim();
existing.collapsed = false;
} else {
this.sections.push({ title, content, collapsed: false });
}
count++;
}
return count;
}
// --- Import d'une structure d'acteur Foundry (template ennemi) ------------
/** Déclenché par le caché : importe la structure choisie. */
onStructureSelected(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (file) this.importStructureFile(file);
}
/**
* Importe une structure Foundry (.json du module) : REMPLACE le template ennemi
* par les champs mappés + pose le type d'acteur. Nécessite un système déjà
* enregistré (id). L'utilisateur élague/renomme ensuite, puis enregistre.
*/
private importStructureFile(file: File): void {
if (!this.id || this.importingStructure) return;
this.importStructureError = null;
file.text().then(txt => {
let structure: unknown;
try {
structure = JSON.parse(txt);
} catch {
this.importStructureError = this.translate.instant('gameSystemEdit.structureInvalid');
return;
}
this.importingStructure = true;
this.service.importFoundryStructure(this.id!, structure).subscribe({
next: (gs) => {
this.enemyTemplate = gs.enemyTemplate ? [...gs.enemyTemplate] : [];
this.foundryActorType = gs.foundryActorType ?? null;
this.importingStructure = false;
},
error: () => {
this.importingStructure = false;
this.importStructureError = this.translate.instant('gameSystemEdit.structureFailed');
}
});
});
}
removeSection(index: number): void {
this.sections.splice(index, 1);
}
toggleCollapse(section: RuleSection): void {
section.collapsed = !section.collapsed;
}
submit(): void {
if (!this.name.trim()) return;
if (this.hasInvalidTemplateFields()) {
console.warn('Champs templates invalides (noms vides ou doublons) — sauvegarde bloquee.');
return;
}
const payload = {
name: this.name.trim(),
description: this.description.trim() || null,
author: this.author.trim() || null,
rulesMarkdown: this.serializeMarkdown(),
characterTemplate: this.characterTemplate,
npcTemplate: this.npcTemplate,
enemyTemplate: this.enemyTemplate,
foundryActorType: this.foundryActorType,
isPublic: false
};
const req = this.id
? this.service.update(this.id, payload)
: this.service.create(payload);
req.subscribe({
next: () => this.back(),
error: () => console.error('Erreur sauvegarde GameSystem')
});
}
back(): void {
this.router.navigate(['/game-systems']);
}
/** Validation cote front : nom vide ou doublons (case-insensitive). */
private hasInvalidTemplateFields(): boolean {
return this.hasInvalidList(this.characterTemplate)
|| this.hasInvalidList(this.npcTemplate)
|| this.hasInvalidList(this.enemyTemplate);
}
private hasInvalidList(fields: TemplateField[]): boolean {
const seen = new Set();
for (const f of fields) {
const name = f.name?.trim().toLowerCase();
if (!name) return true;
if (seen.has(name)) return true;
seen.add(name);
}
return false;
}
// --- Parse / Serialize markdown ------------------------------------------
/**
* Découpe un markdown par titres H2 en sections. Doit rester cohérent avec
* le parser Java côté backend (regex `^##\s+…$`). Le texte avant le premier
* H2 est volontairement perdu — le backend l'ignore aussi pour l'injection IA.
*/
private parseMarkdown(markdown: string): RuleSection[] {
if (!markdown) return [];
const sections: RuleSection[] = [];
const lines = markdown.split('\n');
let current: RuleSection | null = null;
const buffer: string[] = [];
const flush = () => {
if (current) {
current.content = buffer.join('\n').trim();
sections.push(current);
}
buffer.length = 0;
};
for (const line of lines) {
// `\S` force un début de titre non-blanc : frontière déterministe entre
// `\s+` et la capture (l'ancien `.+?`+`\s*$` backtrackait). Le trim()
// en aval retire les espaces de fin.
const match = line.match(/^##\s+(\S.*)$/);
if (match) {
flush();
current = { title: match[1].trim(), content: '', collapsed: false };
} else if (current) {
buffer.push(line);
}
}
flush();
return sections;
}
/** Reconstruit le markdown à partir des sections (ignore les sections vides). */
private serializeMarkdown(): string | null {
const valid = this.sections.filter(s => s.title.trim() || s.content.trim());
if (valid.length === 0) return null;
return valid
.map(s => {
// Le "## " est RESERVE aux titres de section (parseMarkdown decoupe dessus,
// tout comme le parser Java cote backend). Or le contenu genere par l'IA
// contient souvent des sous-titres "## " : sans rien faire, ils seraient
// re-interpretes comme de nouvelles sections au rechargement -> explosion
// de sections + sections vides. On les retrograde donc en "### " (un niveau
// plus bas) pour garder un round-trip stable. (### / #### ne collisionnent pas.)
const content = s.content.trim().replace(/^##[ \t]+/gm, '### ');
return `## ${s.title.trim() || 'Sans titre'}\n${content}`;
})
.join('\n\n');
}
}