File size: 192,036 Bytes
a79154d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
3206
3207
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
3226
3227
3228
3229
3230
3231
3232
3233
3234
3235
3236
3237
3238
3239
3240
3241
3242
3243
3244
3245
3246
3247
3248
3249
3250
3251
3252
3253
3254
3255
3256
3257
3258
3259
3260
3261
3262
3263
3264
3265
3266
3267
3268
3269
3270
3271
3272
3273
3274
3275
3276
3277
3278
3279
3280
3281
3282
3283
3284
3285
3286
3287
3288
3289
3290
3291
3292
3293
3294
3295
3296
3297
3298
3299
3300
3301
3302
3303
3304
3305
3306
3307
3308
3309
3310
3311
3312
3313
3314
3315
3316
3317
3318
3319
3320
3321
3322
3323
3324
3325
3326
3327
3328
3329
3330
3331
3332
3333
3334
3335
3336
3337
3338
3339
3340
3341
3342
3343
3344
3345
3346
3347
3348
3349
3350
3351
3352
3353
3354
3355
3356
3357
3358
3359
3360
3361
3362
3363
3364
3365
3366
3367
3368
3369
3370
3371
3372
3373
3374
3375
3376
3377
3378
3379
3380
3381
3382
3383
3384
3385
3386
3387
3388
3389
3390
3391
3392
3393
3394
3395
3396
3397
3398
3399
3400
3401
3402
3403
3404
3405
3406
3407
3408
3409
3410
3411
3412
3413
3414
3415
3416
3417
3418
3419
3420
3421
3422
3423
3424
3425
3426
3427
3428
3429
3430
3431
3432
3433
3434
3435
3436
3437
3438
3439
3440
3441
3442
3443
3444
3445
3446
3447
3448
3449
3450
3451
3452
3453
3454
3455
3456
3457
3458
3459
3460
3461
3462
3463
3464
3465
3466
3467
3468
3469
3470
3471
3472
3473
3474
3475
3476
3477
3478
3479
3480
3481
3482
3483
3484
3485
3486
3487
3488
3489
3490
3491
3492
3493
3494
3495
3496
3497
3498
3499
3500
3501
3502
3503
3504
3505
3506
3507
3508
3509
3510
3511
3512
3513
3514
3515
3516
3517
3518
3519
3520
3521
3522
3523
3524
3525
3526
3527
3528
3529
3530
3531
3532
3533
3534
3535
3536
3537
3538
3539
3540
3541
import os
import json
import base64
import asyncio
import traceback
import datetime
import threading
import re
from typing import Dict, List, Any, Optional, Tuple
from io import BytesIO

from fastapi import FastAPI, Body, HTTPException, BackgroundTasks, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse, HTMLResponse, Response
from urllib.parse import unquote, quote

import httpx
from openai import OpenAI
from PIL import Image

# HF OAuth (optional - graceful fallback if not available in env)
try:
    from huggingface_hub import attach_huggingface_oauth, parse_huggingface_oauth
    _HF_OAUTH_AVAILABLE = True
except Exception as _hf_oauth_import_err:
    print(f"[OAUTH] huggingface_hub OAuth helpers not available: {_hf_oauth_import_err}")
    _HF_OAUTH_AVAILABLE = False
    attach_huggingface_oauth = None
    parse_huggingface_oauth = None

# ── Environment ──────────────────────────────────────────────
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
# gpt-image-2 (2026-04-21 release). Override via env if needed (e.g. fallback to gpt-image-1.5).
OPENAI_IMAGE_MODEL = os.environ.get("OPENAI_IMAGE_MODEL", "gpt-image-2")
# gpt-image-2 prompt cap is large but we keep a safety margin to avoid 400 errors.
OPENAI_PROMPT_MAX_CHARS = int(os.environ.get("OPENAI_PROMPT_MAX_CHARS", "32000"))
# Resize any input image down to this long-side before sending to the API.
# 2304 matches the largest portrait output (1536x2304 for 4K), so generated
# images sent back through /api/edit do NOT get downscaled — preventing the
# generational quality loss that accumulates when an image is repeatedly
# edited (resize → re-encode → API regenerate → resize again …).
# Quality 95 with 4:4:4 chroma (set in _shrink_image_bytes) keeps re-encode
# loss visually negligible (~45 dB PSNR) at ~3× the bytes of the previous Q90.
OPENAI_INPUT_MAX_SIDE = int(os.environ.get("OPENAI_INPUT_MAX_SIDE", "2304"))
OPENAI_INPUT_JPEG_QUALITY = int(os.environ.get("OPENAI_INPUT_JPEG_QUALITY", "95"))
# Tone-reference: a fixed photo whose color distribution should guide the
# generated model's skin tone. We do NOT pass the original photo (its face,
# outfit, and pose would leak into outputs); instead we mosaic it down to a
# tiny grid (TONE_REF_MOSAIC_GRID cells per axis) so all recognizable form
# is destroyed and only the color palette survives. Applied only on first
# generation (MODE B); skipped for transforms and edits where the existing
# image already dictates the tone.
TONE_REF_ENABLED = os.environ.get("TONE_REF_ENABLED", "1") == "1"
TONE_REF_FILE = os.environ.get("TONE_REF_FILE", "tone_reference.png")
TONE_REF_MOSAIC_W = int(os.environ.get("TONE_REF_MOSAIC_W", "6"))
TONE_REF_MOSAIC_H = int(os.environ.get("TONE_REF_MOSAIC_H", "9"))
HF_TOKEN = os.environ.get("HF_TOKEN", "")
HF_DATASET_REPO = os.environ.get("HF_DATASET_REPO", "")


# ── Shared OpenAI client (reuses httpx connection pool) ──────
# gpt-image-2 can take 30–120s for high quality. A read timeout of 180s is safe;
# connect is short because we fail fast on DNS/network issues.
_OPENAI_HTTP_TIMEOUT = httpx.Timeout(connect=10.0, read=180.0, write=30.0, pool=10.0)
_openai_client_instance: Optional[OpenAI] = None

def get_openai_client() -> OpenAI:
    """Module-level singleton. Reusing the client avoids re-creating the
    underlying httpx pool on every request (~200–500ms/request savings).
    """
    global _openai_client_instance
    if _openai_client_instance is None:
        if not OPENAI_API_KEY:
            raise HTTPException(status_code=500, detail="OPENAI_API_KEY is not configured")
        _openai_client_instance = OpenAI(
            api_key=OPENAI_API_KEY,
            timeout=_OPENAI_HTTP_TIMEOUT,
            # SDK-level retries for transient 5xx/429; our own retry handles size errors.
            max_retries=2,
        )
        print(f"[OPENAI] Initialized shared client (read_timeout={_OPENAI_HTTP_TIMEOUT.read}s)")
    return _openai_client_instance

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=False,
    allow_methods=["*"],
    allow_headers=["*"],
)

# ── HF OAuth ─────────────────────────────────────────────────
# When deployed on a Space with `hf_oauth: true` in README.md, this attaches
# /oauth/huggingface/login and /oauth/huggingface/logout endpoints and enables
# session-based user identification via `parse_huggingface_oauth(request)`.
_OAUTH_ATTACHED = False
if _HF_OAUTH_AVAILABLE:
    try:
        attach_huggingface_oauth(app)
        _OAUTH_ATTACHED = True
        print("[OAUTH] HF OAuth endpoints attached successfully")
    except Exception as _attach_err:
        print(f"[OAUTH] attach_huggingface_oauth failed (likely running outside Space): {_attach_err}")


def get_current_user(request: Request) -> dict:
    """Extract the logged-in user from the HF OAuth session.

    Returns a dict with keys: username, name, email, is_logged_in.
    Falls back to anonymous if OAuth isn't set up or user isn't logged in.
    """
    default = {"username": "anonymous", "name": "", "email": "", "is_logged_in": False}
    if not _OAUTH_ATTACHED or parse_huggingface_oauth is None:
        return default
    try:
        oauth_info = parse_huggingface_oauth(request)
        if oauth_info is None or not getattr(oauth_info, "user_info", None):
            return default
        ui = oauth_info.user_info
        return {
            "username": getattr(ui, "preferred_username", "") or "anonymous",
            "name": getattr(ui, "name", "") or "",
            "email": getattr(ui, "email", "") or "",
            "is_logged_in": True,
        }
    except Exception as parse_err:
        print(f"[OAUTH] parse_huggingface_oauth error: {parse_err}")
        return default


# REQUIRE_LOGIN=true (기본값) 환경에서 OAuth가 붙어 있으면 로그인된 사용자만 API 호출 가능.
# 로컬 개발(OAuth 미연결)에서는 자동으로 anonymous 허용.
REQUIRE_LOGIN = os.environ.get("REQUIRE_LOGIN", "true").lower() in ("1", "true", "yes", "on")

def require_login(current_user: dict) -> None:
    """Raise 401 if OAuth is on but the user isn't logged in."""
    if REQUIRE_LOGIN and _OAUTH_ATTACHED and not current_user["is_logged_in"]:
        raise HTTPException(
            status_code=401,
            detail="로그인이 필요합니다. 우측 상단의 'Hugging Face로 로그인' 버튼을 눌러 주세요.",
        )


# ── Static files ─────────────────────────────────────────────
if os.path.exists("assets"):
    app.mount("/assets", StaticFiles(directory="assets"), name="assets")

_LOGIN_GATE_HTML = """<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/><title>CLOVIS AI 룩북 생성기 · 로그인</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
<style>*{font-family:'Pretendard',-apple-system,sans-serif;box-sizing:border-box}body{margin:0;background:#1a1a1a;color:#f9fafb;min-height:100vh;display:flex;align-items:center;justify-content:center}
.card{width:min(420px,92vw);background:#1e1e1e;border:1px solid #2a2a2a;border-radius:20px;padding:36px 32px;text-align:center}
.logo{width:44px;height:44px;border-radius:12px;background:#3B82F6;display:inline-flex;align-items:center;justify-content:center;margin-bottom:18px}
h1{font-size:18px;margin:0 0 6px}p{font-size:13px;color:#9ca3af;line-height:1.6;margin:0 0 24px}
a.btn{display:block;background:#3B82F6;color:#fff;text-decoration:none;font-weight:600;font-size:14px;padding:13px;border-radius:12px}a.btn:hover{background:#2563EB}
.note{font-size:11.5px;color:#6b7280;margin-top:16px}</style></head><body><div class="card">
<div class="logo"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg></div>
<h1>CLOVIS AI 룩북 생성기</h1><p>이랜드이노플 조직 멤버만 사용할 수 있습니다.<br/>Hugging Face 계정으로 로그인해 주세요.</p>
<a class="btn" href="/oauth/huggingface/login">🤗 Hugging Face로 로그인</a>
<div class="note">로그인 후 자동으로 돌아옵니다. 조직 멤버가 아니면 접근이 거부됩니다.</div></div></body></html>"""


def _serve_index(request: Request):
    """OAuth가 붙어 있고 로그인이 필요한데 미로그인 상태면 앱 대신 로그인 안내 페이지를 준다."""
    if REQUIRE_LOGIN and _OAUTH_ATTACHED and not get_current_user(request)["is_logged_in"]:
        return HTMLResponse(_LOGIN_GATE_HTML, status_code=401)
    return FileResponse("index.html", media_type="text/html")


@app.get("/")
def index(request: Request):
    return _serve_index(request)


@app.get("/api/whoami")
def api_whoami(request: Request):
    """Return the logged-in user info for the frontend to display."""
    user = get_current_user(request)
    return {
        **user,
        "oauth_enabled": _OAUTH_ATTACHED,
        "login_url": "/oauth/huggingface/login" if _OAUTH_ATTACHED else None,
        "logout_url": "/oauth/huggingface/logout" if _OAUTH_ATTACHED else None,
    }


@app.get("/api/health")
def health_check():
    """Diagnostic endpoint for HF Spaces deployment."""
    return {
        "status": "ok",
        "model": OPENAI_IMAGE_MODEL,
        "openai_key_configured": bool(OPENAI_API_KEY),
        "hf_dataset_configured": bool(HF_TOKEN and HF_DATASET_REPO),
        "oauth_attached": _OAUTH_ATTACHED,
        "face_presets_loaded": list(_FACE_PRESET_CACHE.keys()) if '_FACE_PRESET_CACHE' in globals() else [],
    }


# ══════════════════════════════════════════════════════════════
# 사용자별 사용 통계 대시보드
#   - HF Dataset의 logs/**/*.json 을 읽어 사용자별로 집계
#   - /logs/stats     : HTML 대시보드 (사람이 보는 화면)
#   - /api/logs/stats : 집계 JSON (프로그램/디버그용)
#   쿼리: ?from=YYYY-MM-DD&to=YYYY-MM-DD  (기간 필터), ?refresh=1 (캐시 강제 갱신)
# ══════════════════════════════════════════════════════════════

import time as _time

_STATS_CACHE: Dict[str, Any] = {"records": None, "loaded_at": 0.0}
_STATS_TTL_SEC = 60  # 동일 로그를 1분에 한 번만 재다운로드


def _load_log_records(force: bool = False) -> List[dict]:
    """HF Dataset의 logs/**/*.json 을 모두 내려받아 파싱. 60초 인메모리 캐시.

    snapshot_download 는 변경된 파일만 받아오므로(etag 캐시) 반복 호출이 저렴하다.
    """
    now = _time.time()
    cached = _STATS_CACHE.get("records")
    if (not force) and cached is not None and (now - _STATS_CACHE["loaded_at"] < _STATS_TTL_SEC):
        return cached

    records: List[dict] = []
    if not HF_TOKEN or not HF_DATASET_REPO:
        _STATS_CACHE.update(records=records, loaded_at=now)
        return records

    try:
        from huggingface_hub import snapshot_download
        import glob as _glob
        local_dir = snapshot_download(
            repo_id=HF_DATASET_REPO,
            repo_type="dataset",
            allow_patterns=["logs/**/*.json"],
            token=HF_TOKEN,
        )
        for fp in _glob.glob(os.path.join(local_dir, "logs", "**", "*.json"), recursive=True):
            try:
                with open(fp, encoding="utf-8") as f:
                    rec = json.load(f)
                if isinstance(rec, dict):
                    records.append(rec)
            except Exception:
                continue
    except Exception as e:
        print(f"[STATS] 로그 로드 실패: {e}")

    records.sort(key=lambda r: str(r.get("ts") or ""))
    _STATS_CACHE.update(records=records, loaded_at=now)
    return records


def _fmt_ts(ts: str) -> str:
    """YYYYMMDD_HHMMSS_mmm -> 'YYYY-MM-DD HH:MM'"""
    if not ts or len(ts) < 13:
        return ts or ""
    try:
        return f"{ts[0:4]}-{ts[4:6]}-{ts[6:8]} {ts[9:11]}:{ts[11:13]}"
    except Exception:
        return ts


def _fmt_day(day: str) -> str:
    return f"{day[0:4]}-{day[4:6]}-{day[6:8]}" if len(day) == 8 else day


def _rec_day(rec: dict) -> str:
    ts = str(rec.get("ts") or "")
    if len(ts) >= 8 and ts[:8].isdigit():
        return ts[:8]
    tsmp = str(rec.get("timestamp") or "")
    return tsmp[:10].replace("-", "") if tsmp else ""


def _user_key(rec: dict) -> str:
    return (rec.get("user_username") or rec.get("user_email") or "anonymous").strip() or "anonymous"


_STYLE_CODE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]{4,24}$")
_STYLE_CODE_SKIP = frozenset({
    "product", "sub", "sub_product", "persona", "preset", "fashion", "thumb", "image",
})


def _extract_style_code(filename: Optional[str]) -> Optional[str]:
    """Extract fashion style code from an original upload filename.

    Examples:
      CGMW0197M_00.jpg  → CGMW0197M
      SPFZF4TC20.png    → SPFZF4TC20
      SPFZF4TC20_front  → SPFZF4TC20
    """
    if not filename or not isinstance(filename, str):
        return None
    base = os.path.basename(filename.replace("\\", "/")).strip()
    if not base:
        return None
    stem = os.path.splitext(base)[0]
    # Prefer the first underscore-separated token (common: CODE_00 / CODE_front).
    token = stem.split("_")[0].strip()
    if not token or token.lower() in _STYLE_CODE_SKIP:
        return None
    if token.lower().startswith(("product", "preset", "persona", "sub")):
        return None
    if not _STYLE_CODE_RE.match(token):
        return None
    return token.upper()


def _rec_product_filenames(rec: dict) -> List[str]:
    """Collect original product filenames from a log record (best available fields)."""
    names: List[str] = []
    for key in ("product_image_filenames", "product_input_filenames", "sub_product_image_filenames", "sub_product_input_filenames"):
        vals = rec.get(key) or []
        if not isinstance(vals, list):
            continue
        for v in vals:
            if isinstance(v, str) and v.strip():
                names.append(v.strip())
    # Also accept basenames from stored paths.
    for key in ("product_input_paths", "sub_product_input_paths"):
        vals = rec.get(key) or []
        if not isinstance(vals, list):
            continue
        for v in vals:
            if isinstance(v, str) and v.strip():
                names.append(os.path.basename(v.strip()))
    # Deduplicate preserving order.
    seen = set()
    out = []
    for n in names:
        k = n.lower()
        if k in seen:
            continue
        seen.add(k)
        out.append(n)
    return out


def _build_stats_payload(date_from: str = "", date_to: str = "") -> dict:
    """logs/*.json 레코드를 사용자별로 집계해 JSON 직렬화 가능한 dict로 반환."""
    records = _load_log_records()
    df = (date_from or "").replace("-", "")
    dt = (date_to or "").replace("-", "")

    users: Dict[str, dict] = {}
    daily: Dict[str, dict] = {}
    total_gen = total_edit = 0
    all_days = set()
    style_codes: Dict[str, dict] = {}
    recent_inputs: List[dict] = []
    dist = {"shot": {}, "angle": {}, "gaze": {}, "age": {}, "face": {}}

    for rec in records:
        day = _rec_day(rec)
        if not day:
            continue
        if df and day < df:
            continue
        if dt and day > dt:
            continue

        is_edit = (rec.get("type") == "edited")
        uk = _user_key(rec)
        u = users.get(uk)
        if u is None:
            u = users[uk] = {
                "username": uk, "name": "", "email": "", "logged_in": False,
                "gen": 0, "edit": 0, "ref": 0, "bg": 0,
                "angles": {}, "gazes": {},
                "shots": {}, "elapsed": [],
                "first": "", "last": "", "days": set(),
            }
        if rec.get("user_name"):
            u["name"] = rec["user_name"]
        if rec.get("user_email"):
            u["email"] = rec["user_email"]
        if rec.get("user_logged_in"):
            u["logged_in"] = True

        ts = str(rec.get("ts") or "")
        if ts:
            if not u["first"] or ts < u["first"]:
                u["first"] = ts
            if not u["last"] or ts > u["last"]:
                u["last"] = ts
        u["days"].add(day)
        all_days.add(day)

        d = daily.get(day) or daily.setdefault(day, {"day": day, "gen": 0, "edit": 0})

        if is_edit:
            u["edit"] += 1
            d["edit"] += 1
            total_edit += 1
        else:
            u["gen"] += 1
            d["gen"] += 1
            total_gen += 1
            shot = rec.get("shot_label") or "(미지정)"
            u["shots"][shot] = u["shots"].get(shot, 0) + 1
            if rec.get("has_reference"):
                u["ref"] += 1
            if rec.get("has_background"):
                u["bg"] += 1
            opts = rec.get("options") if isinstance(rec.get("options"), dict) else {}
            angle = rec.get("camera_angle") or opts.get("angle") or ""
            gaze = rec.get("gaze") or opts.get("gaze") or ""
            age = rec.get("age_group") or opts.get("age") or ""
            face_id = rec.get("model_face") or ""
            if angle:
                u["angles"][angle] = u["angles"].get(angle, 0) + 1
                dist["angle"][angle] = dist["angle"].get(angle, 0) + 1
            if gaze:
                u["gazes"][gaze] = u["gazes"].get(gaze, 0) + 1
                dist["gaze"][gaze] = dist["gaze"].get(gaze, 0) + 1
            if age:
                dist["age"][age] = dist["age"].get(age, 0) + 1
            if face_id:
                dist["face"][face_id] = dist["face"].get(face_id, 0) + 1
            dist["shot"][shot] = dist["shot"].get(shot, 0) + 1
            es = rec.get("elapsed_sec")
            if isinstance(es, (int, float)):
                u["elapsed"].append(float(es))

            # Style-code / original filename aggregation (generation only).
            filenames = _rec_product_filenames(rec)
            codes_in_rec = []
            for fn in filenames:
                code = _extract_style_code(fn)
                if not code:
                    continue
                codes_in_rec.append(code)
                sc = style_codes.get(code)
                if sc is None:
                    sc = style_codes[code] = {
                        "style_code": code,
                        "gen": 0,
                        "filenames": set(),
                        "users": set(),
                        "last": "",
                    }
                sc["filenames"].add(fn)
                sc["users"].add(uk)
                if ts and (not sc["last"] or ts > sc["last"]):
                    sc["last"] = ts
            for code in set(codes_in_rec):
                style_codes[code]["gen"] += 1

            if filenames:
                recent_inputs.append({
                    "ts": ts,
                    "time": _fmt_ts(ts),
                    "username": uk,
                    "name": rec.get("user_name") or uk,
                    "shot": shot,
                    "filenames": filenames,
                    "style_codes": sorted(set(codes_in_rec)),
                })

    user_list = []
    for u in users.values():
        total = u["gen"] + u["edit"]
        top_shot = max(u["shots"].items(), key=lambda kv: kv[1])[0] if u["shots"] else ""
        top_angle = max(u["angles"].items(), key=lambda kv: kv[1])[0] if u["angles"] else ""
        gaze_total = sum(u["gazes"].values())
        natural_rate = round(u["gazes"].get("자연스러운 시선", 0) / gaze_total * 100) if gaze_total else None
        avg_elapsed = round(sum(u["elapsed"]) / len(u["elapsed"]), 1) if u["elapsed"] else None
        ref_rate = round(u["ref"] / u["gen"] * 100) if u["gen"] else 0
        user_list.append({
            "username": u["username"], "name": u["name"], "email": u["email"],
            "logged_in": u["logged_in"],
            "gen": u["gen"], "edit": u["edit"], "total": total,
            "active_days": len(u["days"]),
            "top_shot": top_shot, "shots": u["shots"],
            "top_angle": top_angle, "angles": u["angles"],
            "gazes": u["gazes"], "natural_gaze_rate": natural_rate,
            "ref_rate": ref_rate, "avg_elapsed": avg_elapsed,
            "first": _fmt_ts(u["first"]), "last": _fmt_ts(u["last"]),
            "last_raw": u["last"],
        })
    user_list.sort(key=lambda x: x["total"], reverse=True)
    daily_list = [daily[d] for d in sorted(daily.keys())]

    style_list = []
    for sc in style_codes.values():
        style_list.append({
            "style_code": sc["style_code"],
            "gen": sc["gen"],
            "users": len(sc["users"]),
            "filenames": sorted(sc["filenames"])[:8],
            "filename_count": len(sc["filenames"]),
            "last": _fmt_ts(sc["last"]),
            "last_raw": sc["last"],
        })
    style_list.sort(key=lambda x: (-x["gen"], x["style_code"]))

    recent_inputs.sort(key=lambda x: x.get("ts") or "", reverse=True)
    recent_inputs = recent_inputs[:80]

    return {
        "summary": {
            "users": len(user_list), "gen": total_gen, "edit": total_edit,
            "total": total_gen + total_edit, "days": len(all_days),
            "style_codes": len(style_list),
            "first_day": _fmt_day(min(all_days)) if all_days else "",
            "last_day": _fmt_day(max(all_days)) if all_days else "",
            "configured": bool(HF_TOKEN and HF_DATASET_REPO),
            "repo": HF_DATASET_REPO or "(미설정)",
            "loaded_at": datetime.datetime.fromtimestamp(_STATS_CACHE["loaded_at"]).strftime("%Y-%m-%d %H:%M:%S") if _STATS_CACHE["loaded_at"] else "",
        },
        "users": user_list,
        "daily": daily_list,
        "dist": {k: sorted(v.items(), key=lambda kv: -kv[1]) for k, v in dist.items()},
        "style_codes": style_list,
        "recent_inputs": recent_inputs,
        "filter": {"from": date_from, "to": date_to},
    }


def _render_stats_html(payload: dict) -> str:
    data_json = json.dumps(payload, ensure_ascii=False).replace("</", "<\\/")
    return _STATS_HTML_TEMPLATE.replace("__DATA__", data_json)


@app.get("/api/logs/stats")
def api_logs_stats(request: Request):
    qp = request.query_params
    if qp.get("refresh"):
        _load_log_records(force=True)
    return JSONResponse(_build_stats_payload(qp.get("from", ""), qp.get("to", "")))


@app.get("/logs/stats", response_class=HTMLResponse)
def logs_stats_page(request: Request):
    qp = request.query_params
    if qp.get("refresh"):
        _load_log_records(force=True)
    payload = _build_stats_payload(qp.get("from", ""), qp.get("to", ""))
    return HTMLResponse(_render_stats_html(payload))


_STATS_HTML_TEMPLATE = r"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>AI 룩북 · 사용 통계</title>
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
<style>
  :root{
    --bg:#f7f8fa; --panel:#ffffff; --border:#e6e8ec; --ink:#1a202c;
    --muted:#6b7280; --accent:#2563eb; --accent-soft:#eff4ff;
    --edit:#0d9488; --warn:#d97706; --track:#eef1f5;
  }
  *{box-sizing:border-box}
  body{margin:0;background:var(--bg);color:var(--ink);
    font-family:'Pretendard',-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
    -webkit-font-smoothing:antialiased;}
  a{color:inherit}
  .wrap{max-width:1180px;margin:0 auto;padding:28px 20px 60px}
  header{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:6px}
  h1{font-size:22px;font-weight:800;margin:0;letter-spacing:-.02em}
  .sub{color:var(--muted);font-size:13px;margin-top:4px}
  .sub code{background:#eef1f5;padding:1px 6px;border-radius:5px;font-size:12px}
  .toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
  .toolbar input[type=date]{font-family:inherit;border:1px solid var(--border);border-radius:8px;
    padding:7px 9px;font-size:13px;background:#fff;color:var(--ink)}
  .btn{border:1px solid var(--border);background:#fff;border-radius:8px;padding:8px 13px;
    font-size:13px;font-weight:600;cursor:pointer;color:var(--ink);text-decoration:none;
    display:inline-flex;align-items:center;gap:6px;transition:.15s}
  .btn:hover{border-color:var(--accent);color:var(--accent)}
  .btn.primary{background:var(--accent);color:#fff;border-color:var(--accent)}
  .btn.primary:hover{filter:brightness(1.05);color:#fff}

  .cards{display:grid;grid-template-columns:repeat(6,1fr);gap:12px;margin:22px 0}
  @media(max-width:960px){.cards{grid-template-columns:repeat(3,1fr)}}
  @media(max-width:560px){.cards{grid-template-columns:repeat(2,1fr)}}
  .card{background:var(--panel);border:1px solid var(--border);border-radius:14px;padding:16px 18px}
  .card .k{font-size:12px;color:var(--muted);font-weight:600;letter-spacing:.01em}
  .card .v{font-size:26px;font-weight:800;margin-top:6px;letter-spacing:-.02em}
  .card .v small{font-size:13px;font-weight:600;color:var(--muted)}

  .panel{background:var(--panel);border:1px solid var(--border);border-radius:16px;padding:18px 20px;margin-bottom:18px}
  .panel h2{font-size:14px;font-weight:700;margin:0 0 14px;display:flex;align-items:center;gap:8px}
  .panel h2 .tag{font-size:11px;font-weight:600;color:var(--muted)}
  .fn{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px;
    background:#f3f5f8;padding:2px 7px;border-radius:6px;display:inline-block;margin:1px 3px 1px 0;
    max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle}
  .codepill{display:inline-block;font-size:12px;font-weight:700;letter-spacing:.02em;
    padding:3px 9px;border-radius:8px;background:#111827;color:#fff}

  /* daily chart */
  .chart{display:flex;align-items:flex-end;gap:6px;height:160px;overflow-x:auto;padding-bottom:6px}
  .dist-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:18px}
  .dist h3{font-size:12px;color:var(--muted);margin:0 0 8px;font-weight:600}
  .dist .row{display:grid;grid-template-columns:96px 1fr 44px;align-items:center;gap:8px;font-size:12px;margin:4px 0}
  .dist .row .n{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .dist .row .t{height:8px;border-radius:999px;background:var(--track);overflow:hidden}
  .dist .row .t i{display:block;height:100%;background:var(--accent);border-radius:999px}
  .dist .row .c{text-align:right;color:var(--muted);font-variant-numeric:tabular-nums}
  .col{display:flex;flex-direction:column;align-items:center;gap:6px;min-width:26px;flex:0 0 auto}
  .bar{width:18px;border-radius:5px 5px 0 0;background:var(--track);position:relative;display:flex;flex-direction:column-reverse;overflow:hidden}
  .seg-gen{background:var(--accent)}
  .seg-edit{background:var(--edit)}
  .col .lbl{font-size:10px;color:var(--muted);white-space:nowrap;transform:rotate(-45deg);transform-origin:center;height:30px;margin-top:4px}
  .legend{display:flex;gap:16px;font-size:12px;color:var(--muted);margin-top:6px}
  .legend span{display:inline-flex;align-items:center;gap:6px}
  .dot{width:10px;height:10px;border-radius:3px;display:inline-block}

  table{width:100%;border-collapse:collapse;font-size:13px}
  thead th{text-align:left;font-weight:600;color:var(--muted);font-size:12px;
    padding:10px 10px;border-bottom:1px solid var(--border);white-space:nowrap;cursor:pointer;user-select:none}
  thead th.num{text-align:right}
  thead th:hover{color:var(--accent)}
  thead th .arrow{opacity:.4;font-size:10px;margin-left:3px}
  thead th.active .arrow{opacity:1;color:var(--accent)}
  tbody td{padding:11px 10px;border-bottom:1px solid #f1f3f6;vertical-align:middle}
  tbody tr:hover{background:#fafbfc}
  td.num{text-align:right;font-variant-numeric:tabular-nums}
  .u-name{font-weight:700}
  .u-id{color:var(--muted);font-size:11.5px;margin-top:1px}
  a.ulink{text-decoration:none;color:inherit;display:inline-block}
  a.ulink:hover .u-name{color:var(--accent);text-decoration:underline}
  .u-name .chev{font-size:11px;color:var(--accent);margin-left:5px;opacity:.7}
  .pill{display:inline-block;font-size:11px;font-weight:600;padding:2px 8px;border-radius:999px;background:var(--accent-soft);color:var(--accent)}
  .pill.gray{background:#f1f3f6;color:var(--muted)}
  .pill.anon{background:#fdf2f2;color:#b91c1c}
  .barmini{height:6px;border-radius:4px;background:var(--track);overflow:hidden;width:60px;display:inline-block;vertical-align:middle;margin-right:6px}
  .barmini > i{display:block;height:100%;background:var(--accent)}
  .empty{text-align:center;color:var(--muted);padding:40px 0;font-size:14px}
  .foot{color:var(--muted);font-size:12px;margin-top:18px;text-align:center}
</style>
</head>
<body>
<div class="wrap">
  <header>
    <div>
      <h1>AI 룩북 · 사용 통계</h1>
      <div class="sub">데이터셋 <code id="repo"></code> · 마지막 갱신 <span id="loaded"></span></div>
    </div>
    <div class="toolbar">
      <input type="date" id="from"/>
      <span style="color:var(--muted)">~</span>
      <input type="date" id="to"/>
      <button class="btn primary" onclick="applyFilter()">기간 적용</button>
      <button class="btn" onclick="resetFilter()">전체</button>
      <a class="btn" id="refreshBtn" href="?refresh=1">↻ 새로고침</a>
    </div>
  </header>

  <div class="cards" id="cards"></div>

  <div class="panel">
    <h2>일자별 활동 <span class="tag" id="rangeTag"></span></h2>
    <div class="chart" id="chart"></div>
    <div class="legend">
      <span><i class="dot" style="background:var(--accent)"></i>생성</span>
      <span><i class="dot" style="background:var(--edit)"></i>편집</span>
    </div>
  </div>

  <div class="panel">
    <h2>샷 · 앵글 · 시선 분포 <span class="tag">생성 기준</span></h2>
    <div class="dist-grid" id="dist"></div>
  </div>

  <div class="panel">
    <h2>스타일코드별 생성 <span class="tag">원본 파일명에서 추출 · 열 제목 클릭 정렬</span></h2>
    <div style="overflow-x:auto">
      <table id="styleTable">
        <thead>
          <tr>
            <th data-k="style_code" data-t="str" data-table="style">스타일코드<span class="arrow"></span></th>
            <th class="num active" data-k="gen" data-table="style">생성<span class="arrow">▼</span></th>
            <th class="num" data-k="users" data-table="style">사용자<span class="arrow"></span></th>
            <th data-k="filenames" data-t="str" data-table="style">원본 파일명 예시<span class="arrow"></span></th>
            <th data-k="last_raw" data-t="str" data-table="style">최근 생성<span class="arrow"></span></th>
          </tr>
        </thead>
        <tbody id="styleBody"></tbody>
      </table>
    </div>
    <div class="empty" id="styleEmpty" style="display:none">스타일코드를 추출할 원본 파일명 로그가 없습니다. (배포 이후 생성분부터 집계됩니다)</div>
  </div>

  <div class="panel">
    <h2>최근 생성 · 원본 파일명 <span class="tag">최대 80건</span></h2>
    <div style="overflow-x:auto">
      <table>
        <thead>
          <tr>
            <th>시각</th>
            <th>사용자</th>
            <th>샷</th>
            <th>스타일코드</th>
            <th>원본 파일명</th>
          </tr>
        </thead>
        <tbody id="recentBody"></tbody>
      </table>
    </div>
    <div class="empty" id="recentEmpty" style="display:none">원본 파일명이 기록된 최근 생성이 없습니다.</div>
  </div>

  <div class="panel">
    <h2>사용자별 사용 현황 <span class="tag">열 제목을 클릭하면 정렬됩니다</span></h2>
    <div style="overflow-x:auto">
      <table id="userTable">
        <thead>
          <tr>
            <th data-k="name" data-t="str" data-table="user">사용자<span class="arrow"></span></th>
            <th class="num" data-k="gen" data-table="user">생성<span class="arrow"></span></th>
            <th class="num" data-k="edit" data-table="user">편집<span class="arrow"></span></th>
            <th class="num active" data-k="total" data-table="user">합계<span class="arrow">▼</span></th>
            <th class="num" data-k="active_days" data-table="user">활동일<span class="arrow"></span></th>
            <th data-k="top_shot" data-t="str" data-table="user">주 사용 샷<span class="arrow"></span></th>
            <th data-k="top_angle" data-t="str" data-table="user">주 앵글<span class="arrow"></span></th>
            <th class="num" data-k="natural_gaze_rate" data-table="user">자연 시선율<span class="arrow"></span></th>
            <th class="num" data-k="ref_rate" data-table="user">레퍼런스율<span class="arrow"></span></th>
            <th class="num" data-k="avg_elapsed" data-table="user">평균 소요<span class="arrow"></span></th>
            <th data-k="last_raw" data-t="str" data-table="user">최근 활동<span class="arrow"></span></th>
          </tr>
        </thead>
        <tbody id="tbody"></tbody>
      </table>
    </div>
    <div class="empty" id="empty" style="display:none">표시할 로그가 없습니다.</div>
  </div>

  <div class="foot" id="foot"></div>
</div>

<script>
const DATA = __DATA__;
let sortKey = "total", sortType = "num", sortDir = -1;
let styleSortKey = "gen", styleSortType = "num", styleSortDir = -1;

function esc(s){return String(s==null?"":s).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));}

function renderCards(){
  const s = DATA.summary;
  const range = (s.first_day && s.last_day) ? (s.first_day + " ~ " + s.last_day) : "데이터 없음";
  document.getElementById("repo").textContent = s.repo;
  document.getElementById("loaded").textContent = s.loaded_at || "-";
  document.getElementById("rangeTag").textContent = range;
  const cards = [
    ["사용자", s.users, ""],
    ["총 생성", s.gen, "건"],
    ["총 편집", s.edit, "건"],
    ["스타일코드", s.style_codes || 0, "종"],
    ["활동 일수", s.days, "일"],
    ["기간", "", range],
  ];
  document.getElementById("cards").innerHTML = cards.map(([k,v,suf])=>{
    const val = (k==="기간") ? `<span style="font-size:14px;font-weight:700">${esc(suf)||"-"}</span>`
                             : `${Number(v).toLocaleString()}${suf?` <small>${suf}</small>`:""}`;
    return `<div class="card"><div class="k">${k}</div><div class="v">${val}</div></div>`;
  }).join("");
  if(!s.configured){
    document.getElementById("foot").innerHTML =
      "⚠️ HF_TOKEN / HF_DATASET_REPO 환경변수가 설정되지 않아 로그를 불러올 수 없습니다. (로컬 개발 환경)";
  }
}

function renderChart(){
  const daily = DATA.daily || [];
  const el = document.getElementById("chart");
  if(!daily.length){ el.innerHTML = '<div class="empty" style="width:100%">활동 기록이 없습니다.</div>'; return; }
  const max = Math.max(...daily.map(d=>d.gen+d.edit), 1);
  const H = 120;
  el.innerHTML = daily.map(d=>{
    const tot = d.gen+d.edit;
    const h = Math.round(tot/max*H);
    const gh = tot ? Math.round(d.gen/tot*h) : 0;
    const eh = h - gh;
    const day = d.day, lbl = day.slice(4,6)+"/"+day.slice(6,8);
    return `<div class="col" title="${day.slice(0,4)}-${day.slice(4,6)}-${day.slice(6,8)} · 생성 ${d.gen} / 편집 ${d.edit}">
      <div class="bar" style="height:${Math.max(h,3)}px">
        <div class="seg-edit" style="height:${eh}px"></div>
        <div class="seg-gen" style="height:${gh}px"></div>
      </div>
      <div class="lbl">${lbl}</div>
    </div>`;
  }).join("");
}

function renderDist(){
  const d = DATA.dist || {};
  const el = document.getElementById("dist");
  const groups = [["샷","shot"],["카메라 앵글","angle"],["시선","gaze"],["연령","age"],["얼굴 프리셋","face"]];
  const html = groups.map(([title,key])=>{
    const rows = d[key] || [];
    if(!rows.length) return "";
    const total = rows.reduce((a,[,c])=>a+c,0) || 1;
    const max = rows[0][1] || 1;
    return `<div class="dist"><h3>${title}</h3>` + rows.map(([name,c])=>
      `<div class="row"><span class="n" title="${esc(name)}">${esc(name)}</span><span class="t"><i style="width:${Math.round(c/max*100)}%"></i></span><span class="c">${c} · ${Math.round(c/total*100)}%</span></div>`
    ).join("") + `</div>`;
  }).filter(Boolean).join("");
  el.innerHTML = html || '<div class="empty" style="width:100%">앵글·시선 정보가 기록된 생성이 없습니다. (Comfy 브리지 배포 이후 생성분부터 집계됩니다)</div>';
}

function renderTable(){
  const rows = [...DATA.users];
  rows.sort((a,b)=>{
    let x=a[sortKey], y=b[sortKey];
    if(sortType==="str"){ x=(x||"").toString().toLowerCase(); y=(y||"").toString().toLowerCase();
      return x<y?-1*sortDir:x>y?1*sortDir:0; }
    x = (x==null?-1:Number(x)); y = (y==null?-1:Number(y));
    return (x-y)*sortDir;
  });
  const tb = document.getElementById("tbody");
  const empty = document.getElementById("empty");
  if(!rows.length){ tb.innerHTML=""; empty.style.display="block"; return; }
  empty.style.display="none";
  const maxTotal = Math.max(...rows.map(r=>r.total),1);
  const f=DATA.filter||{}; const qs=[]; if(f.from)qs.push("from="+f.from); if(f.to)qs.push("to="+f.to);
  const dateQS = qs.length?("?"+qs.join("&")):"";
  tb.innerHTML = rows.map(u=>{
    const isAnon = u.username==="anonymous";
    const href = "/logs/user/"+encodeURIComponent(u.username)+dateQS;
    const inner = `<div class="u-name">${esc(u.name || u.username)}<span class="chev">›</span></div>`
      + (isAnon ? `<span class="pill anon">비로그인</span>`
                : `<div class="u-id">@${esc(u.username)}${u.email?` · ${esc(u.email)}`:""}</div>`);
    const nameCell = `<a class="ulink" href="${href}">${inner}</a>`;
    const totalBar = `<span class="barmini"><i style="width:${Math.round(u.total/maxTotal*100)}%"></i></span>`;
    const shot = u.top_shot ? `<span class="pill gray">${esc(u.top_shot)}</span>` : "-";
    const angle = u.top_angle ? `<span class="pill gray">${esc(u.top_angle)}</span>` : "-";
    const gazeTip = Object.entries(u.gazes||{}).map(([k,v])=>`${k} ${v}`).join(" / ");
    const gaze = (u.natural_gaze_rate==null) ? "-" : `<span title="${esc(gazeTip)}">${u.natural_gaze_rate}%</span>`;
    const avg = (u.avg_elapsed==null) ? "-" : `${u.avg_elapsed}s`;
    return `<tr>
      <td>${nameCell}</td>
      <td class="num">${u.gen.toLocaleString()}</td>
      <td class="num">${u.edit.toLocaleString()}</td>
      <td class="num">${totalBar}<b>${u.total.toLocaleString()}</b></td>
      <td class="num">${u.active_days}</td>
      <td>${shot}</td>
      <td>${angle}</td>
      <td class="num">${gaze}</td>
      <td class="num">${u.ref_rate}%</td>
      <td class="num">${avg}</td>
      <td>${esc(u.last)}</td>
    </tr>`;
  }).join("");
}

function renderStyleTable(){
  const rows = [...(DATA.style_codes||[])];
  rows.sort((a,b)=>{
    let x=a[styleSortKey], y=b[styleSortKey];
    if(styleSortKey==="filenames"){
      x=(a.filenames&&a.filenames[0]||"").toLowerCase();
      y=(b.filenames&&b.filenames[0]||"").toLowerCase();
      return x<y?-1*styleSortDir:x>y?1*styleSortDir:0;
    }
    if(styleSortType==="str"){ x=(x||"").toString().toLowerCase(); y=(y||"").toString().toLowerCase();
      return x<y?-1*styleSortDir:x>y?1*styleSortDir:0; }
    x = (x==null?-1:Number(x)); y = (y==null?-1:Number(y));
    return (x-y)*styleSortDir;
  });
  const tb = document.getElementById("styleBody");
  const empty = document.getElementById("styleEmpty");
  if(!rows.length){ tb.innerHTML=""; empty.style.display="block"; return; }
  empty.style.display="none";
  const maxGen = Math.max(...rows.map(r=>r.gen),1);
  tb.innerHTML = rows.map(r=>{
    const bar = `<span class="barmini"><i style="width:${Math.round(r.gen/maxGen*100)}%"></i></span>`;
    const files = (r.filenames||[]).map(f=>`<span class="fn" title="${esc(f)}">${esc(f)}</span>`).join("");
    const more = (r.filename_count||0) > (r.filenames||[]).length
      ? `<span class="pill gray">+${r.filename_count - r.filenames.length}</span>` : "";
    return `<tr>
      <td><span class="codepill">${esc(r.style_code)}</span></td>
      <td class="num">${bar}<b>${r.gen.toLocaleString()}</b></td>
      <td class="num">${r.users}</td>
      <td>${files||"-"}${more}</td>
      <td>${esc(r.last)||"-"}</td>
    </tr>`;
  }).join("");
}

function renderRecentInputs(){
  const rows = DATA.recent_inputs || [];
  const tb = document.getElementById("recentBody");
  const empty = document.getElementById("recentEmpty");
  if(!rows.length){ tb.innerHTML=""; empty.style.display="block"; return; }
  empty.style.display="none";
  tb.innerHTML = rows.map(r=>{
    const codes = (r.style_codes||[]).map(c=>`<span class="codepill">${esc(c)}</span>`).join(" ") || "-";
    const files = (r.filenames||[]).map(f=>`<span class="fn" title="${esc(f)}">${esc(f)}</span>`).join("") || "-";
    return `<tr>
      <td>${esc(r.time)||"-"}</td>
      <td><div class="u-name">${esc(r.name||r.username)}</div><div class="u-id">@${esc(r.username)}</div></td>
      <td>${r.shot?`<span class="pill gray">${esc(r.shot)}</span>`:"-"}</td>
      <td>${codes}</td>
      <td>${files}</td>
    </tr>`;
  }).join("");
}

document.querySelectorAll("thead th[data-k]").forEach(th=>{
  th.addEventListener("click",()=>{
    const table = th.dataset.table || "user";
    const k = th.dataset.k, t = th.dataset.t==="str"?"str":"num";
    if(table==="style"){
      if(styleSortKey===k){ styleSortDir *= -1; } else { styleSortKey=k; styleSortType=t; styleSortDir = (t==="str")?1:-1; }
      document.querySelectorAll("#styleTable thead th").forEach(x=>{x.classList.remove("active"); const a=x.querySelector(".arrow"); if(a)a.textContent="";});
      th.classList.add("active");
      th.querySelector(".arrow").textContent = styleSortDir<0?"▼":"▲";
      renderStyleTable();
      return;
    }
    if(sortKey===k){ sortDir *= -1; } else { sortKey=k; sortType=t; sortDir = (t==="str")?1:-1; }
    document.querySelectorAll("#userTable thead th").forEach(x=>{x.classList.remove("active"); const a=x.querySelector(".arrow"); if(a)a.textContent="";});
    th.classList.add("active");
    th.querySelector(".arrow").textContent = sortDir<0?"▼":"▲";
    renderTable();
  });
});

function applyFilter(){
  const f=document.getElementById("from").value, t=document.getElementById("to").value;
  const p=new URLSearchParams();
  if(f)p.set("from",f); if(t)p.set("to",t);
  location.search = p.toString();
}
function resetFilter(){ location.search=""; }

(function init(){
  if(DATA.filter){
    if(DATA.filter.from) document.getElementById("from").value=DATA.filter.from;
    if(DATA.filter.to) document.getElementById("to").value=DATA.filter.to;
  }
  const q=new URLSearchParams(location.search);
  const keep=[]; if(q.get("from"))keep.push("from="+q.get("from")); if(q.get("to"))keep.push("to="+q.get("to"));
  document.getElementById("refreshBtn").href = "?refresh=1"+(keep.length?"&"+keep.join("&"):"");
  renderCards(); renderChart(); renderDist(); renderStyleTable(); renderRecentInputs(); renderTable();
})();
</script>
</body>
</html>"""



# ══════════════════════════════════════════════════════════════
# 사용자별 상세 갤러리
#   - 각 생성/편집의 입력·출력 이미지, 프롬프트, 소요시간을 보여줌
#   - /logs/user/{username}     : HTML 갤러리
#   - /api/logs/user/{username} : 상세 JSON
#   - /logs/asset/{path}        : private dataset 이미지 프록시(토큰으로 받아 스트리밍)
# ══════════════════════════════════════════════════════════════

import re as _re

_DSINDEX_CACHE: Dict[str, Any] = {"index": None, "loaded_at": 0.0}
_TS_RE = _re.compile(r"(\d{8}_\d{6}_\d{3})")
_ASSET_PREFIXES = ("images/", "thumbs/", "inputs/product/", "inputs/persona/")


def _load_dataset_index(force: bool = False) -> Dict[str, dict]:
    """dataset의 전체 파일 목록을 한 번 받아 ts -> {output, thumb, persona, products} 로 매핑."""
    now = _time.time()
    cached = _DSINDEX_CACHE.get("index")
    if (not force) and cached is not None and (now - _DSINDEX_CACHE["loaded_at"] < _STATS_TTL_SEC):
        return cached

    index: Dict[str, dict] = {}
    if not HF_TOKEN or not HF_DATASET_REPO:
        _DSINDEX_CACHE.update(index=index, loaded_at=now)
        return index
    try:
        from huggingface_hub import HfApi
        files = HfApi(token=HF_TOKEN).list_repo_files(repo_id=HF_DATASET_REPO, repo_type="dataset")
    except Exception as e:
        print(f"[STATS] list_repo_files 실패: {e}")
        files = []

    for fp in files:
        m = _TS_RE.search(fp)
        if not m:
            continue
        ts = m.group(1)
        slot = index.setdefault(ts, {"output": None, "thumb": None, "persona": None, "products": []})
        if fp.startswith("images/"):
            slot["output"] = fp
        elif fp.startswith("thumbs/"):
            slot["thumb"] = fp
        elif fp.startswith("inputs/persona/"):
            slot["persona"] = fp
        elif fp.startswith("inputs/product/"):
            slot["products"].append(fp)
    for slot in index.values():
        slot["products"].sort()

    _DSINDEX_CACHE.update(index=index, loaded_at=now)
    return index


_SLOT_KO = {"outer": "아우터", "top": "상의", "bottom": "하의", "shoes": "신발", "inner": "이너웨어",
            "belt": "벨트", "background": "배경", "pose": "포즈"}
_DETAIL_KIND_KO = {"texture": "조직감", "material": "소재", "logo": "로고", "neckline": "넥라인", "collar": "카라",
                   "sleeve": "소매", "hem": "밑단", "waist": "허리", "pocket": "포켓", "closure": "지퍼·버튼",
                   "trim": "부자재", "sole": "밑창", "lacing": "끈", "heel": "뒤축", "buckle": "버클", "edge": "스티치"}


def _comfy_input_labels(rec: dict, paths: list) -> tuple:
    """Comfy 기록의 입력 이미지 라벨/순서 복원.
    저장 시 순서: product_image_filenames(메인 의상 + 디테일 참조) → sub/(배경·포즈).
    파일명은 push 시 _unique_filenames 로 정리되므로 같은 함수를 적용해 basename 으로 매칭한다."""
    sf = rec.get("slot_filenames") or {}
    ref_keys = [k for k in sf if k in ("background", "pose")]
    main_keys = [k for k in sf if k not in ("background", "pose")]
    drefs = [d for d in (rec.get("detail_refs") or []) if isinstance(d, dict)]
    pnames = [str(x) for x in (rec.get("product_image_filenames") or [])]

    main_labels = [_SLOT_KO.get(k, k) for k in main_keys] + \
                  [f"{_SLOT_KO.get(d.get('garment'), d.get('garment'))}·{_DETAIL_KIND_KO.get(d.get('kind'), d.get('kind'))}" for d in drefs]
    if len(pnames) != len(main_labels):          # 형식이 안 맞으면 예전 방식으로
        return [_SLOT_KO.get(k, k) for k in sf.keys()], paths

    expected = [(False, n, lb) for n, lb in zip(_unique_filenames(pnames, "product"), main_labels)] + \
               [(True, n, _SLOT_KO.get(k, k)) for n, k in zip(_unique_filenames([str(sf[k]) for k in ref_keys], "sub_product"), ref_keys)]
    remaining = list(paths)
    ordered, labels = [], []
    for is_sub, name, label in expected:
        hit = next((p for p in remaining if ("/sub/" in p) == is_sub and os.path.basename(p) == name), None)
        if hit is None:
            continue
        remaining.remove(hit)
        ordered.append(hit)
        labels.append(label)
    for p in remaining:                           # 매칭 안 된 파일은 뒤에 그대로
        ordered.append(p)
        labels.append("제품")
    return labels, ordered


def _asset_url(path: Optional[str]) -> Optional[str]:
    return ("/logs/asset/" + path) if path else None


@app.get("/logs/asset/{path:path}")
def logs_asset(path: str):
    """private dataset 이미지를 토큰으로 받아 그대로 반환(브라우저 <img>가 직접 못 받으니 프록시)."""
    decoded = unquote(path)
    if not decoded.startswith(_ASSET_PREFIXES) or ".." in decoded:
        raise HTTPException(status_code=404, detail="not allowed")
    if not HF_TOKEN or not HF_DATASET_REPO:
        raise HTTPException(status_code=404, detail="dataset not configured")
    url = f"https://huggingface.co/datasets/{HF_DATASET_REPO}/resolve/main/{quote(decoded, safe='/')}"
    try:
        r = httpx.get(url, headers={"Authorization": f"Bearer {HF_TOKEN}"},
                      timeout=20.0, follow_redirects=True)
    except Exception as e:
        raise HTTPException(status_code=502, detail=f"fetch failed: {e}")
    if r.status_code != 200:
        raise HTTPException(status_code=404, detail="asset not found")
    media = r.headers.get("content-type", "application/octet-stream")
    return Response(content=r.content, media_type=media,
                    headers={"Cache-Control": "public, max-age=3600"})


def _build_user_detail(username: str, date_from: str = "", date_to: str = "", limit: int = 200) -> dict:
    records = _load_log_records()
    index = _load_dataset_index()
    df = (date_from or "").replace("-", "")
    dt = (date_to or "").replace("-", "")

    items = []
    name = email = ""
    logged_in = False
    gen = edit = 0
    elapsed_all = []

    for rec in records:
        if _user_key(rec) != username:
            continue
        day = _rec_day(rec)
        if df and day < df:
            continue
        if dt and day > dt:
            continue

        ts = str(rec.get("ts") or "")
        assets = index.get(ts, {})
        is_edit = (rec.get("type") == "edited")
        if rec.get("user_name"):
            name = rec["user_name"]
        if rec.get("user_email"):
            email = rec["user_email"]
        if rec.get("user_logged_in"):
            logged_in = True

        es = rec.get("elapsed_sec")
        products = []
        for p in (rec.get("product_infos") or []):
            if isinstance(p, dict):
                products.append(p.get("category") or p.get("name") or "제품")
            elif p:
                products.append(str(p))
        product_paths = list(assets.get("products") or [])
        if not products and isinstance(rec.get("slot_filenames"), dict):
            # Comfy 브리지 기록: 저장 순서(메인 의상 → 디테일 참조 → sub/배경·포즈)대로 파일을 재정렬하고 라벨을 붙인다.
            # 폴더 목록은 알파벳순이라 디테일 참조(원본 파일명)가 앞으로 끼어들 수 있음.
            products, product_paths = _comfy_input_labels(rec, product_paths)

        items.append({
            "ts": ts, "time": _fmt_ts(ts), "day": day,
            "kind": "편집" if is_edit else "생성", "is_edit": is_edit,
            "shot": rec.get("shot_label") or "",
            "mode": rec.get("mode") or "",
            "angle": rec.get("camera_angle") or (rec.get("options") or {}).get("angle") or "",
            "gaze": rec.get("gaze") or (rec.get("options") or {}).get("gaze") or "",
            "model_face": rec.get("model_face") or "",
            "elapsed": es if isinstance(es, (int, float)) else None,
            "has_reference": bool(rec.get("has_reference")),
            "has_background": bool(rec.get("has_background")),
            "is_background_change": bool(rec.get("is_background_change")),
            "custom_prompt": ((rec.get("edit_instruction") if is_edit else rec.get("custom_prompt")) or ""),
            "full_prompt": rec.get("prompt") or "",
            "product_cats": products,
            "output_url": _asset_url(assets.get("output") or assets.get("thumb")),
            "thumb_url": _asset_url(assets.get("thumb") or assets.get("output")),
            "persona_url": _asset_url(assets.get("persona")),
            "product_urls": [_asset_url(p) for p in product_paths],
        })
        if is_edit:
            edit += 1
        else:
            gen += 1
            if isinstance(es, (int, float)):
                elapsed_all.append(float(es))

    items.sort(key=lambda x: x["ts"], reverse=True)
    truncated = len(items) > limit
    items = items[:limit]
    avg = round(sum(elapsed_all) / len(elapsed_all), 1) if elapsed_all else None

    return {
        "user": {"username": username, "name": name, "email": email, "logged_in": logged_in},
        "summary": {
            "gen": gen, "edit": edit, "total": gen + edit, "avg_elapsed": avg,
            "shown": len(items), "truncated": truncated, "limit": limit,
            "configured": bool(HF_TOKEN and HF_DATASET_REPO),
        },
        "items": items,
        "filter": {"from": date_from, "to": date_to},
    }


def _render_user_html(payload: dict) -> str:
    data_json = json.dumps(payload, ensure_ascii=False).replace("</", "<\\/")
    return _USER_HTML_TEMPLATE.replace("__DATA__", data_json)


@app.get("/api/my/history")
def api_my_history(request: Request):
    """로그인한 본인의 생성 기록 (HF Dataset 영구 저장분). 앱의 '지난 기록' 탭에서 사용."""
    user = get_current_user(request)
    if _OAUTH_ATTACHED and not user["is_logged_in"]:
        raise HTTPException(status_code=401, detail="로그인이 필요합니다.")
    if request.query_params.get("refresh"):
        _load_log_records(force=True)
        _load_dataset_index(force=True)
    payload = _build_user_detail(user["username"], limit=60)
    items = [{
        "ts": it["ts"], "time": it["time"],
        "shot": it["shot"], "angle": it["angle"], "gaze": it["gaze"],
        "face": it["model_face"], "elapsed": it["elapsed"],
        "prompt": it["custom_prompt"], "fullPrompt": it["full_prompt"],
        "imageUrl": it["output_url"], "thumbUrl": it["thumb_url"],
    } for it in payload["items"] if it.get("output_url")]
    return JSONResponse({"items": items, "truncated": payload["summary"]["truncated"],
                         "configured": payload["summary"]["configured"]})


@app.get("/api/logs/user/{username}")
def api_logs_user(request: Request, username: str):
    qp = request.query_params
    if qp.get("refresh"):
        _load_log_records(force=True)
        _load_dataset_index(force=True)
    return JSONResponse(_build_user_detail(unquote(username), qp.get("from", ""), qp.get("to", "")))


@app.get("/logs/user/{username}", response_class=HTMLResponse)
def logs_user_page(request: Request, username: str):
    qp = request.query_params
    if qp.get("refresh"):
        _load_log_records(force=True)
        _load_dataset_index(force=True)
    payload = _build_user_detail(unquote(username), qp.get("from", ""), qp.get("to", ""))
    return HTMLResponse(_render_user_html(payload))


_USER_HTML_TEMPLATE = r"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>사용 상세 · AI 룩북</title>
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"/>
<style>
  :root{--bg:#f7f8fa;--panel:#fff;--border:#e6e8ec;--ink:#1a202c;--muted:#6b7280;
        --accent:#2563eb;--accent-soft:#eff4ff;--edit:#0d9488;--warn:#d97706;}
  *{box-sizing:border-box}
  body{margin:0;background:var(--bg);color:var(--ink);
    font-family:'Pretendard',-apple-system,BlinkMacSystemFont,system-ui,sans-serif;-webkit-font-smoothing:antialiased}
  a{color:inherit}
  .wrap{max-width:1180px;margin:0 auto;padding:24px 20px 60px}
  .back{display:inline-flex;align-items:center;gap:6px;font-size:13px;font-weight:600;color:var(--muted);text-decoration:none;margin-bottom:14px}
  .back:hover{color:var(--accent)}
  header{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:18px}
  h1{font-size:21px;font-weight:800;margin:0;letter-spacing:-.02em}
  h1 .uid{font-size:13px;font-weight:600;color:var(--muted);margin-left:8px}
  .meta-sum{display:flex;gap:18px;margin-top:8px;font-size:13px;color:var(--muted);flex-wrap:wrap}
  .meta-sum b{color:var(--ink);font-weight:700}
  .btn{border:1px solid var(--border);background:#fff;border-radius:8px;padding:7px 12px;font-size:13px;font-weight:600;cursor:pointer;color:var(--ink);text-decoration:none}
  .btn:hover{border-color:var(--accent);color:var(--accent)}

  .grid{display:grid;grid-template-columns:repeat(2,1fr);gap:16px}
  @media(max-width:820px){.grid{grid-template-columns:1fr}}
  .card{background:var(--panel);border:1px solid var(--border);border-radius:16px;overflow:hidden;display:flex;flex-direction:column}
  .card-top{display:flex;gap:14px;padding:14px}
  .outwrap{flex:0 0 168px;width:168px}
  .outwrap a{display:block;border-radius:10px;overflow:hidden;border:1px solid var(--border);background:#f0f2f5;aspect-ratio:3/4}
  .outwrap img{width:100%;height:100%;object-fit:cover;display:block}
  .card-body{flex:1;min-width:0}
  .badges{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}
  .badge{font-size:11px;font-weight:700;padding:3px 9px;border-radius:999px;background:var(--accent-soft);color:var(--accent)}
  .badge.edit{background:#e6fffb;color:var(--edit)}
  .badge.gray{background:#f1f3f6;color:var(--muted)}
  .badge.time{background:#fff7ed;color:var(--warn)}
  .when{font-size:12px;color:var(--muted);margin-bottom:10px}
  .inputs{margin-top:6px}
  .inputs .lbl{font-size:11px;font-weight:700;color:var(--muted);margin-bottom:5px}
  .thumbs{display:flex;gap:7px;flex-wrap:wrap}
  .thumbs .t{position:relative;width:52px;height:64px;border-radius:7px;overflow:hidden;border:1px solid var(--border);background:#f0f2f5}
  .thumbs .t img{width:100%;height:100%;object-fit:cover;display:block}
  .thumbs .t .tag{position:absolute;left:0;bottom:0;right:0;font-size:8.5px;text-align:center;background:rgba(0,0,0,.55);color:#fff;padding:1px 0}
  .noimg{font-size:12px;color:#aab;padding:6px 0}
  .prompt{border-top:1px solid #f1f3f6;padding:12px 14px;background:#fcfcfd}
  .prompt .lbl{font-size:11px;font-weight:700;color:var(--muted);margin-bottom:5px;display:flex;justify-content:space-between;align-items:center}
  .prompt .box{font-size:12.5px;line-height:1.55;color:#222;white-space:pre-wrap;word-break:break-word;
    background:#fff;border:1px solid var(--border);border-radius:9px;padding:9px 11px;max-height:140px;overflow:auto}
  .prompt .box.empty{color:#aab;font-style:italic}
  .togglefull{font-size:11px;font-weight:600;color:var(--accent);cursor:pointer;user-select:none}
  .fullbox{display:none;margin-top:8px}
  .fullbox.open{display:block}
  .empty{text-align:center;color:var(--muted);padding:60px 0;font-size:14px}
  .foot{color:var(--muted);font-size:12px;margin-top:20px;text-align:center}
</style>
</head>
<body>
<div class="wrap">
  <a class="back" href="/logs/stats" id="backlink">← 전체 통계로</a>
  <header>
    <div>
      <h1 id="title"></h1>
      <div class="meta-sum" id="sum"></div>
    </div>
    <a class="btn" id="refreshBtn" href="?refresh=1">↻ 새로고침</a>
  </header>
  <div class="grid" id="grid"></div>
  <div class="empty" id="empty" style="display:none">표시할 생성 기록이 없습니다.</div>
  <div class="foot" id="foot"></div>
</div>

<script>
const DATA = __DATA__;
function esc(s){return String(s==null?"":s).replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));}
const u=DATA.user, s=DATA.summary, f=DATA.filter||{};
const qs=[]; if(f.from)qs.push("from="+f.from); if(f.to)qs.push("to="+f.to);
const dateQS=qs.length?("?"+qs.join("&")):"";
document.getElementById("backlink").href="/logs/stats"+dateQS;
document.getElementById("refreshBtn").href="?refresh=1"+(qs.length?"&"+qs.join("&"):"");

document.getElementById("title").innerHTML =
  esc(u.name||u.username) + (u.username==="anonymous" ? '<span class="uid">비로그인</span>'
    : `<span class="uid">@${esc(u.username)}${u.email?" · "+esc(u.email):""}</span>`);
document.getElementById("sum").innerHTML =
  `<span>생성 <b>${s.gen}</b></span><span>편집 <b>${s.edit}</b></span>`
  + `<span>합계 <b>${s.total}</b></span>`
  + (s.avg_elapsed!=null?`<span>평균 소요 <b>${s.avg_elapsed}초</b></span>`:"")
  + (f.from||f.to?`<span>기간 <b>${esc(f.from||"~")} ~ ${esc(f.to||"~")}</b></span>`:"");

if(!s.configured){
  document.getElementById("foot").textContent="⚠️ HF_TOKEN / HF_DATASET_REPO 미설정 — 이미지를 불러올 수 없습니다.";
}
if(s.truncated){
  document.getElementById("foot").textContent=`최근 ${s.limit}건만 표시했습니다. 더 보려면 기간 필터를 사용하세요.`;
}

function imgOrNone(url, alt, tag){
  if(!url) return "";
  return `<div class="t"><img loading="lazy" src="${url}" alt="${esc(alt)}" onerror="this.parentNode.style.display='none'"/>${tag?`<span class="tag">${esc(tag)}</span>`:""}</div>`;
}

function card(it){
  const badges=[];
  badges.push(`<span class="badge ${it.is_edit?'edit':''}">${it.kind}</span>`);
  if(it.shot) badges.push(`<span class="badge gray">${esc(it.shot)}</span>`);
  if(it.angle) badges.push(`<span class="badge gray">${esc(it.angle)}</span>`);
  if(it.gaze) badges.push(`<span class="badge gray">${esc(it.gaze)}</span>`);
  if(it.mode && it.mode!=="COMFY") badges.push(`<span class="badge gray">${esc(it.mode)}</span>`);
  if(it.is_edit && it.is_background_change) badges.push(`<span class="badge gray">배경변경</span>`);
  if(it.elapsed!=null) badges.push(`<span class="badge time">⏱ ${it.elapsed}초</span>`);

  // 입력 이미지 스트립
  let inputsHtml="";
  if(!it.is_edit){
    const parts=[];
    if(it.persona_url) parts.push(imgOrNone(it.persona_url,"persona","페르소나"));
    (it.product_urls||[]).forEach((purl,i)=>{
      const cat=(it.product_cats&&it.product_cats[i])?it.product_cats[i]:("제품"+(i+1));
      parts.push(imgOrNone(purl,"product",cat));
    });
    inputsHtml = parts.join("") || `<div class="noimg">저장된 입력 이미지 없음</div>`;
  } else {
    inputsHtml = `<div class="noimg">편집 — 입력은 직전 결과 이미지</div>`;
  }

  const outImg = it.thumb_url
    ? `<a href="${it.output_url||it.thumb_url}" target="_blank" rel="noopener"><img loading="lazy" src="${it.thumb_url}" alt="output" onerror="this.parentNode.innerHTML='<div class=&quot;noimg&quot; style=&quot;padding:20px&quot;>출력 이미지 없음</div>'"/></a>`
    : `<a><div class="noimg" style="padding:30px 8px;text-align:center">출력 이미지 없음</div></a>`;

  const promptText = it.custom_prompt && it.custom_prompt!=="(none)" ? it.custom_prompt : "";
  const promptLabel = it.is_edit ? "편집 지시" : "사용자 프롬프트";
  const fullToggle = it.full_prompt
    ? `<span class="togglefull" onclick="this.closest('.prompt').querySelector('.fullbox').classList.toggle('open');this.textContent=this.textContent.includes('▾')?'전체 프롬프트 ▸':'전체 프롬프트 ▾'">전체 프롬프트 ▸</span>`
    : "";

  return `<div class="card">
    <div class="card-top">
      <div class="outwrap">${outImg}</div>
      <div class="card-body">
        <div class="badges">${badges.join("")}</div>
        <div class="when">${esc(it.time)} · ${esc(it.ts)}</div>
        <div class="inputs"><div class="lbl">입력 이미지</div><div class="thumbs">${inputsHtml}</div></div>
      </div>
    </div>
    <div class="prompt">
      <div class="lbl"><span>${promptLabel}</span>${fullToggle}</div>
      <div class="box ${promptText?'':'empty'}">${promptText?esc(promptText):"(입력한 프롬프트 없음)"}</div>
      ${it.full_prompt?`<div class="fullbox"><div class="box">${esc(it.full_prompt)}</div></div>`:""}
    </div>
  </div>`;
}

(function(){
  const items=DATA.items||[];
  if(!items.length){ document.getElementById("empty").style.display="block"; return; }
  document.getElementById("grid").innerHTML = items.map(card).join("");
})();
</script>
</body>
</html>"""


@app.get("/index.css")
def index_css():
    if os.path.exists("index.css"):
        return FileResponse("index.css", media_type="text/css")
    return JSONResponse(content={}, status_code=204)

@app.get("/{filename}")
def serve_root_files(request: Request, filename: str):
    decoded = unquote(filename)
    if decoded.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.ico', '.svg', '.webp')):
        if os.path.exists(decoded) and not decoded.startswith("."):
            return FileResponse(decoded)
        raise HTTPException(status_code=404, detail=f"Image not found: {decoded}")
    if decoded.startswith(".") or decoded.endswith((".py", ".json", ".txt", ".md", ".env")):
        # 스캐너 봇의 /.env, /server.py 류 요청은 index.html 로 응답하지 않는다
        raise HTTPException(status_code=404, detail="Not found")
    if os.path.exists("index.html"):
        return _serve_index(request)
    raise HTTPException(status_code=404, detail="Not found")


# ── Image preprocessing ──────────────────────────────────────

def _shrink_image_bytes(
    raw: bytes,
    max_side: int = OPENAI_INPUT_MAX_SIDE,
    jpeg_quality: int = OPENAI_INPUT_JPEG_QUALITY,
    preserve_format_if_possible: bool = False,
) -> Tuple[bytes, str]:
    """Resize an image to `max_side` on its longest edge and re-encode as JPEG.

    Rationale: face presets (~2–3 MB PNG) and user uploads blow up both upload
    time and the model's input token count on `images.edit`. A 2304-long-side
    JPEG at Q95 with 4:4:4 chroma typically lands at ~0.6–1.2 MB with no
    visible quality loss, even after repeated edit cycles.

    Why these specific knobs (verified by simulation, see comments at the top
    of this file):
      • max_side=2304 matches the largest portrait OUTPUT size (1536x2304 for
        4K), so a generated image fed back through /api/edit is NOT downscaled.
        Without this, every edit pass shrinks the image by ~25–33 % only for
        the API to regenerate it back up — accumulating real detail loss.
      • quality=95 lifts JPEG re-encode artifacts from ~32 dB PSNR (Q90, very
        visible after 3+ edits) to ~45 dB (essentially indistinguishable).
      • subsampling=0 (4:4:4) keeps full-resolution chroma. Pillow's default
        below Q95 is 4:2:0, which halves color channels and accumulates
        color-bleed/mosquito-noise on every edit cycle.

    Idempotent in practice: feeding the encoder its own output again converges
    on a fixed point — measured drift across 5 generations is ~0.06 dB PSNR.
    Returns: (bytes, mime_type). Falls back to the original bytes on failure.
    """
    try:
        img = Image.open(BytesIO(raw))
        original_format = (img.format or "").upper()
        w, h = img.size
        long_side = max(w, h)
        if preserve_format_if_possible and long_side <= max_side:
            if original_format == "PNG" and img.mode in ("RGB", "RGBA"):
                return raw, "image/png"
            if original_format in ("JPEG", "JPG") and img.mode == "RGB":
                return raw, "image/jpeg"

        # gpt-image-2 accepts RGB. Flatten alpha onto white (safer for garments
        # on white backgrounds; we don't use transparency anywhere in outputs).
        if img.mode in ("RGBA", "LA"):
            bg = Image.new("RGB", img.size, (255, 255, 255))
            bg.paste(img, mask=img.split()[-1])
            img = bg
        elif img.mode == "P":
            img = img.convert("RGBA")
            bg = Image.new("RGB", img.size, (255, 255, 255))
            bg.paste(img, mask=img.split()[-1])
            img = bg
        elif img.mode != "RGB":
            img = img.convert("RGB")

        if long_side > max_side:
            scale = max_side / long_side
            new_size = (max(1, int(w * scale)), max(1, int(h * scale)))
            img = img.resize(new_size, Image.LANCZOS)

        buf = BytesIO()
        img.save(
            buf,
            format="JPEG",
            quality=jpeg_quality,
            optimize=True,
            progressive=False,
            subsampling=0,  # 4:4:4 — preserves full chroma across edit cycles
        )
        out = buf.getvalue()
        return out, "image/jpeg"
    except Exception as e:
        print(f"[RESIZE] Failed ({type(e).__name__}: {e}); returning original bytes")
        return raw, "image/png"


def _shrink_base64(b64_data: str) -> Tuple[str, str]:
    """Convenience: take a base64-encoded image, shrink it, return (b64, mime)."""
    try:
        raw = base64.b64decode(b64_data)
    except Exception:
        return b64_data, "image/png"
    out_bytes, mime = _shrink_image_bytes(raw)
    return base64.b64encode(out_bytes).decode("utf-8"), mime


# ── Server-side face presets ──────────────────────────────────
_FACE_PRESET_CACHE: Dict[str, dict] = {}

def _load_face_presets():
    preset_files = [
        ("preset_17", "face_preset_17.png"),
        ("preset_18", "face_preset_18.png"),
        ("preset_19", "face_preset_19.png"),
    ]
    total_before = 0
    total_after = 0
    for pid, filename in preset_files:
        path = os.path.join(os.path.dirname(__file__) or ".", filename)
        if os.path.exists(path):
            with open(path, "rb") as f:
                raw = f.read()
            # Pre-resize once at startup; preserve PNG when already within API max side.
            shrunk, mime = _shrink_image_bytes(raw, preserve_format_if_possible=True)
            b64 = base64.b64encode(shrunk).decode("utf-8")
            _FACE_PRESET_CACHE[pid] = {"data": b64, "mime_type": mime}
            total_before += len(raw)
            total_after += len(shrunk)
            print(f"[FACE] Loaded preset '{pid}' from {filename} ({len(raw):,} → {len(shrunk):,} bytes, {mime})")
        else:
            print(f"[FACE] Preset file not found: {path}")
    if total_before > 0:
        saved_pct = 100 * (1 - total_after / total_before)
        print(f"[FACE] Total preset memory: {total_before:,} → {total_after:,} bytes (-{saved_pct:.0f}%)")

_load_face_presets()


# ── Server-side tone reference (color palette) ────────────────
# A single static photo whose color distribution defines the target skin
# tone / color grading for generated models. Stored as a heavily mosaiced
# image so all recognizable structure (face, outfit, pose, background) is
# destroyed — only the color palette remains. The model receives this as
# the SECOND input image in MODE B and is instructed via the prompt to
# treat it strictly as a color reference.
_TONE_REF_CACHE: Dict[str, Any] = {}

def _load_tone_ref():
    if not TONE_REF_ENABLED:
        print("[TONE] Disabled via TONE_REF_ENABLED=0")
        return
    path = os.path.join(os.path.dirname(__file__) or ".", TONE_REF_FILE)
    if not os.path.exists(path):
        print(f"[TONE] Reference file not found: {path} — feature inactive")
        return
    try:
        with open(path, "rb") as f:
            raw = f.read()
        palette = _build_tone_palette_image(raw)
        if not palette:
            raise ValueError("palette build failed")
        b64 = palette["data"]
        mime = palette["mime_type"]
        _TONE_REF_CACHE["data"] = b64
        _TONE_REF_CACHE["mime_type"] = mime
        print(
            f"[TONE] Loaded reference from {TONE_REF_FILE} "
            f"(palette strip {TONE_REF_MOSAIC_W} swatches, "
            f"{len(base64.b64decode(b64)):,} bytes, {mime})"
        )
    except Exception as e:
        print(f"[TONE] Failed to load reference ({type(e).__name__}: {e}) — feature inactive")


def _get_tone_ref_part() -> Optional[dict]:
    """Return the tone-reference inline_data part, or None if unavailable/disabled."""
    if not TONE_REF_ENABLED or "data" not in _TONE_REF_CACHE:
        return None
    return {
        "inline_data": {
            "data": _TONE_REF_CACHE["data"],
            "mime_type": _TONE_REF_CACHE["mime_type"],
        }
    }


def _build_tone_palette_image(image_bytes: bytes) -> Optional[Dict[str, str]]:
    """Build a tiny horizontal palette strip — color info only, never a scene background."""
    try:
        src = Image.open(BytesIO(image_bytes)).convert("RGB")
        grid_w, grid_h = TONE_REF_MOSAIC_W, TONE_REF_MOSAIC_H
        tiny = src.resize((grid_w, grid_h), Image.LANCZOS)

        swatch_w = 14
        strip_h = 16
        strip = Image.new("RGB", (grid_w * swatch_w, strip_h))
        for x in range(grid_w):
            colors = [tiny.getpixel((x, y)) for y in range(grid_h)]
            r = sum(c[0] for c in colors) // grid_h
            g = sum(c[1] for c in colors) // grid_h
            b = sum(c[2] for c in colors) // grid_h
            color = (r, g, b)
            for dx in range(swatch_w):
                for y in range(strip_h):
                    strip.putpixel((x * swatch_w + dx, y), color)

        buf = BytesIO()
        strip.save(buf, format="PNG")
        shrunk, mime = _shrink_image_bytes(buf.getvalue())
        return {
            "data": base64.b64encode(shrunk).decode("utf-8"),
            "mime_type": mime,
        }
    except Exception as e:
        print(f"[TONE] Failed to build tone palette strip ({type(e).__name__}: {e})")
        return None


def _extract_tone_palette_text(image_bytes: bytes) -> str:
    """Summarize dominant colors as text so the model can grade without copying a grid."""
    try:
        src = Image.open(BytesIO(image_bytes)).convert("RGB")
        tiny = src.resize((TONE_REF_MOSAIC_W, TONE_REF_MOSAIC_H), Image.LANCZOS)
        hex_colors = []
        for x in range(TONE_REF_MOSAIC_W):
            colors = [tiny.getpixel((x, y)) for y in range(TONE_REF_MOSAIC_H)]
            r = sum(c[0] for c in colors) // len(colors)
            g = sum(c[1] for c in colors) // len(colors)
            b = sum(c[2] for c in colors) // len(colors)
            hex_colors.append(f"#{r:02x}{g:02x}{b:02x}")
        return "Target palette (grade only): " + ", ".join(hex_colors)
    except Exception:
        return ""


def _mosaic_image_for_tone_ref(image_bytes: bytes) -> Optional[Dict[str, str]]:
    """Backward-compatible alias — palette strip only."""
    return _build_tone_palette_image(image_bytes)


def _get_user_tone_ref_part(tone_image_b64: str) -> Optional[dict]:
    if not tone_image_b64 or not isinstance(tone_image_b64, str) or not tone_image_b64.startswith("data:"):
        return None
    parsed = _parse_base64(tone_image_b64)
    palette = _build_tone_palette_image(base64.b64decode(parsed["data"]))
    if not palette:
        return None
    return {
        "inline_data": {
            "data": palette["data"],
            "mime_type": palette["mime_type"],
        }
    }


def _parse_user_tone_reference(tone_image_b64: str) -> Tuple[Optional[dict], str]:
    """Return palette strip (ORIGINAL only) and palette text for prompt grading."""
    if not tone_image_b64 or not isinstance(tone_image_b64, str) or not tone_image_b64.startswith("data:"):
        return None, ""
    parsed = _parse_base64(tone_image_b64)
    raw = base64.b64decode(parsed["data"])
    palette_text = _extract_tone_palette_text(raw)
    tone_part = _get_user_tone_ref_part(tone_image_b64)
    return tone_part, palette_text


def _build_tone_background_lock() -> str:
    return (
        "TONE BACKGROUND LOCK (CRITICAL): The visible scene background MUST remain a continuous soft light-gray photography studio backdrop. "
        "Do NOT render any mosaic grid, checkerboard, color tiles, palette swatches, or pattern from the tone reference as the background. "
        "The tone reference influences color grade and mood only — never the background texture or layout."
    )


def _build_tone_reference_instruction(
    mode: str = "original",
    has_face: bool = True,
    palette_text: str = "",
) -> str:
    palette_line = f"{palette_text}. " if palette_text else ""
    background_lock = _build_tone_background_lock()
    if mode == "transform":
        return (
            f"TONE / MOOD REFERENCE (CRITICAL): {palette_line}"
            "Use this palette guidance ONLY for overall color temperature, contrast, saturation, white balance, and photographic mood. "
            "Apply a similar tone and mood while preserving the source image's model identity, face, outfit, pose, framing, composition, and studio background exactly. "
            "DO NOT copy garment colors, background layout, mosaic patterns, or facial identity from the tone reference. "
            f"{background_lock}"
        )
    position = "The small palette strip immediately after the face reference" if has_face else "The small palette strip"
    return (
        f"TONE / MOOD REFERENCE (CRITICAL — READ CAREFULLY): {position} is NOT a person, outfit, pose, scene, or background plate. "
        f"It is a tiny horizontal color palette extracted from a user reference photograph. {palette_line}"
        "USE IT FOR: matching overall color temperature, white balance, contrast, saturation, and photographic mood. "
        "DO NOT: copy any face, hair, outfit, garment color, pose, framing, background layout, grid, or tile pattern from this image. "
        "The model's face identity must still come from the face reference — apply the tone reference to global color grade and atmosphere only. "
        f"{background_lock}"
    )


_load_tone_ref()


@app.get("/api/face-presets")
def get_face_presets():
    preset_labels = {
        "preset_17": "여성 1", "preset_18": "여성 2", "preset_19": "여성 3",
    }
    presets = []
    for pid in _FACE_PRESET_CACHE:
        presets.append({
            "id": pid,
            "label": preset_labels.get(pid, pid),
            "url": f"/{pid.replace('preset_', 'face_preset_')}.png",
        })
    return {"presets": presets}


# ── Helpers ──────────────────────────────────────────────────

def _parse_base64(data_url: str) -> dict:
    if ";base64," in data_url:
        header, data = data_url.split(";base64,", 1)
        mime = header.split(":", 1)[1] if ":" in header else "image/png"
        return {"mime_type": mime, "data": data}
    return {"mime_type": "image/png", "data": data_url}


def _hair_identity_lock(gender: str) -> str:
    """Front-view hair: reduce left/right asymmetry and random restyles vs face reference."""
    if gender == "male":
        return (
            "HAIR IDENTITY (CRITICAL): Match the face reference hair precisely — same cut, length, and side volume; "
            "do NOT randomize thickness, wave, or introduce asymmetric styling not visible in the reference."
        )
    return (
        "HAIR IDENTITY LOCK (CRITICAL — match face reference): Replicate hair EXACTLY: same visible length on LEFT and RIGHT "
        "(no one-side-only short cuts or 'half different haircut'), same part line (center vs side), same bangs/forehead exposure, "
        "same straight/wave texture and overall volume. Do NOT randomize hair texture or layering. "
        "Long hair must fall naturally past both shoulders with balanced symmetry in front view. "
        "Do NOT tuck behind one ear only unless clearly shown in the reference."
    )


def _resolve_image_size(payload_size: Any) -> str:
    """Default to 2K; allow 1K / 2K / 4K per request."""
    if not payload_size:
        return "2K"
    try:
        v = str(payload_size).strip().upper()
    except Exception:
        return "2K"
    return v if v in {"1K", "2K", "4K"} else "2K"


def _openai_image_size(image_size_key: Any) -> str:
    """Map UI 1K/2K/4K to gpt-image-2 portrait ``size``.

    gpt-image-2 accepts flexible resolutions; however, to guarantee
    compatibility we use the officially documented portrait sizes and
    fall back to `auto` for higher tiers that may fail at account level.
    Officially supported portrait sizes for GPT Image family: 1024x1536.
    """
    tier = _resolve_image_size(image_size_key)
    if tier == "1K":
        return "1024x1536"
    if tier == "4K":
        # gpt-image-2 accepts flexible resolutions but very large sizes may
        # exceed the per-request token/pixel budget on some accounts.
        # Use a safe high-res portrait; let the model handle fidelity.
        return "1536x2304"
    # 2K default
    return "1536x2048"


def _openai_quality(image_size_key: Any) -> str:
    """Pick quality based on requested tier."""
    tier = _resolve_image_size(image_size_key)
    if tier == "4K":
        return "high"
    if tier == "2K":
        return "high"
    return "medium"


def _openai_prompt_and_image_files(parts: List[dict]) -> tuple:
    """Split Gemini-style content parts into a single prompt and binary image inputs.

    Every inline image is passed through `_shrink_image_bytes` here. Face presets
    use `preserve_format=True` so PNG bytes are not re-encoded to JPEG before API upload.
    """
    text_chunks: List[str] = []
    image_files: List[BytesIO] = []
    total_before = 0
    total_after = 0
    for p in parts:
        if "text" in p:
            text_chunks.append(p["text"])
        elif "inline_data" in p:
            idata = p["inline_data"]
            raw_b64 = idata["data"]
            raw = base64.b64decode(raw_b64) if isinstance(raw_b64, str) else raw_b64
            total_before += len(raw)
            # Edit source images can preserve PNG/JPEG bytes when they already
            # fit the input size; this avoids adding compression texture that
            # the image model may amplify on skin.
            shrunk, shrunk_mime = _shrink_image_bytes(
                raw,
                preserve_format_if_possible=bool(p.get("preserve_format")),
            )
            total_after += len(shrunk)
            buf = BytesIO(shrunk)
            buf.name = "image.jpg" if "jpeg" in shrunk_mime else "image.png"
            image_files.append(buf)
    if image_files and total_before > 0:
        saved_pct = 100 * (1 - total_after / total_before)
        print(f"[RESIZE] {len(image_files)} input image(s): {total_before:,} → {total_after:,} bytes (-{saved_pct:.0f}%)")
    prompt = "\n\n".join(text_chunks)
    # Guard against rare "prompt too long" errors from the API.
    if len(prompt) > OPENAI_PROMPT_MAX_CHARS:
        print(f"[OPENAI] Prompt length {len(prompt)} > {OPENAI_PROMPT_MAX_CHARS}; truncating.")
        prompt = prompt[:OPENAI_PROMPT_MAX_CHARS]
    return prompt, image_files


def _translate_openai_error(err: Exception) -> HTTPException:
    """OpenAI SDK / API 에러를 적절한 HTTP 상태코드 + 한글 메시지로 변환.

    OpenAI 에러는 `getattr(err, "body", dict)["error"]["code"]`에 유형이 담겨 있고,
    SDK가 못 받은 경우를 대비해 메시지 문자열로도 한 번 더 매칭.
    """
    code = ""
    msg_raw = ""
    try:
        body = getattr(err, "body", None) or {}
        if isinstance(body, dict):
            err_obj = body.get("error") or {}
            code = (err_obj.get("code") or "").lower()
            msg_raw = err_obj.get("message") or ""
    except Exception:
        pass

    s = (str(err) + " " + code + " " + msg_raw).lower()

    # 결제 한도 (가장 먼저 체크 — 400으로 위장되어 오는 경우가 있음)
    if "billing_hard_limit_reached" in s or "billing hard limit" in s:
        return HTTPException(
            status_code=402,
            detail="OpenAI API 결제 한도에 도달했습니다. 관리자에게 사용량 한도 상향을 요청해 주세요.",
        )
    if "insufficient_quota" in s:
        return HTTPException(
            status_code=402,
            detail="OpenAI API 크레딧이 부족합니다. 결제 정보를 확인해 주세요.",
        )

    # Rate limit
    if "rate_limit" in s or "rate limit" in s or "too many requests" in s or " 429" in s:
        return HTTPException(
            status_code=429,
            detail="요청이 몰려 일시적으로 제한되었습니다. 잠시 후 다시 시도해 주세요.",
        )

    # 콘텐츠 정책
    if "content_policy" in s or "safety_violation" in s or "safety system" in s or "moderation_blocked" in s:
        return HTTPException(
            status_code=400,
            detail="이미지 생성이 OpenAI 콘텐츠 정책에 의해 거부되었습니다. 프롬프트나 입력 이미지를 확인해 주세요.",
        )

    # 인증
    if "invalid_api_key" in s or "incorrect api key" in s or "authentication" in s:
        return HTTPException(
            status_code=401,
            detail="OpenAI API 키가 유효하지 않거나 설정되지 않았습니다. 환경변수를 확인해 주세요.",
        )

    # 모델 접근 권한
    if "model_not_found" in s or "does not have access to model" in s or "not_found" in s:
        return HTTPException(
            status_code=403,
            detail=f"모델 '{OPENAI_IMAGE_MODEL}'에 접근할 수 없습니다. 계정 등급/모델 액세스 권한을 확인해 주세요.",
        )

    # 타임아웃 / 네트워크
    if "timeout" in s or "timed out" in s:
        return HTTPException(
            status_code=504,
            detail="OpenAI API 응답 시간이 초과되었습니다. 잠시 후 다시 시도해 주세요.",
        )
    if "connection" in s and ("error" in s or "refused" in s or "reset" in s):
        return HTTPException(
            status_code=502,
            detail="OpenAI API 서버에 연결할 수 없습니다. 네트워크 상태를 확인해 주세요.",
        )

    # 잘못된 요청 (사이즈 외 기타)
    if "invalid_request" in s or "bad request" in s:
        short = (msg_raw or str(err))[:300]
        return HTTPException(
            status_code=400,
            detail=f"요청이 OpenAI API에 의해 거부되었습니다: {short}",
        )

    # 그 외 (원문 메시지 일부 첨부)
    short = (msg_raw or str(err))[:300]
    return HTTPException(
        status_code=502,
        detail=f"이미지 생성 중 오류가 발생했습니다: {short}",
    )


def _openai_image_generate(client: OpenAI, parts: List[dict], image_size_key: Any) -> str:
    """gpt-image-2: images.edit with reference images, else images.generate.

    Important notes for gpt-image-2 (2026-04-21):
      - `response_format` is NOT supported (the API always returns b64_json).
      - `input_fidelity` is NOT supported — gpt-image-2 auto-processes input
        images at high fidelity internally.
      - `background="transparent"` is NOT supported.
      - Size parameter is flexible but we use documented portrait sizes.
    """
    prompt, image_files = _openai_prompt_and_image_files(parts)

    size_value = _openai_image_size(image_size_key)
    quality_value = _openai_quality(image_size_key)

    common = {
        "model": OPENAI_IMAGE_MODEL,
        "prompt": prompt,
        "size": size_value,
        "quality": quality_value,
        "n": 1,
    }

    try:
        if image_files:
            for f in image_files:
                f.seek(0)
            print(f"[OPENAI] images.edit | model={OPENAI_IMAGE_MODEL} | size={size_value} | quality={quality_value} | refs={len(image_files)}")
            result = client.images.edit(image=image_files, **common)
        else:
            print(f"[OPENAI] images.generate | model={OPENAI_IMAGE_MODEL} | size={size_value} | quality={quality_value}")
            result = client.images.generate(**common)
    except Exception as api_err:
        # Retry with `size="auto"` if the requested size is rejected.
        err_str = str(api_err).lower()
        is_size_err = any(k in err_str for k in ("size", "dimension", "resolution", "pixel"))
        if is_size_err and size_value != "auto":
            print(f"[OPENAI] Size '{size_value}' rejected: {api_err}. Retrying with size='auto'.")
            common["size"] = "auto"
            try:
                if image_files:
                    for f in image_files:
                        f.seek(0)
                    result = client.images.edit(image=image_files, **common)
                else:
                    result = client.images.generate(**common)
            except Exception as retry_err:
                print(f"[OPENAI] Retry failed: {retry_err}")
                raise _translate_openai_error(retry_err)
        else:
            print(f"[OPENAI] API error: {api_err}")
            raise _translate_openai_error(api_err)

    if not getattr(result, "data", None):
        raise HTTPException(status_code=500, detail="No image was returned by OpenAI.")
    b64 = result.data[0].b64_json
    if not b64:
        # Some SDK versions expose URL instead; attempt to read `url` as fallback.
        url = getattr(result.data[0], "url", None)
        if url:
            raise HTTPException(status_code=500, detail=f"OpenAI returned URL instead of b64 data: {url}")
        raise HTTPException(status_code=500, detail="No image data in OpenAI response.")
    return f"data:image/png;base64,{b64}"


def _get_face_parts(model_face) -> list:
    parts = []
    face_data = None
    face_mime = None

    if isinstance(model_face, str):
        if model_face.startswith("preset_") and model_face in _FACE_PRESET_CACHE:
            cached = _FACE_PRESET_CACHE[model_face]
            face_data = cached["data"]
            face_mime = cached["mime_type"]
            print(f"[FACE] Using preset '{model_face}' ({len(face_data)} base64 chars)")
        elif model_face.startswith("data:"):
            parsed = _parse_base64(model_face)
            face_data = parsed["data"]
            face_mime = parsed["mime_type"]
            print(f"[FACE] Using user upload ({len(face_data)} base64 chars)")

    if face_data:
        parts.append({
            "inline_data": {"data": face_data, "mime_type": face_mime},
            "preserve_format": True,
        })
        parts.append({"text": (
            "[FACE REFERENCE IMAGE] This is the EXACT face/person to use as the model. "
            "You MUST preserve this person's facial identity with extreme precision: "
            "same face shape, eye shape, eyebrow shape, nose, lips, and hairstyle. "
            "FACIAL SKIN (HIGHEST PRIORITY): Copy the reference face's skin EXACTLY — same even tone, same smoothness, same lighting. "
            "Render face skin as one clean continuous catalog tone matching the reference. "
            "Do NOT add under-eye darkness, cheek stains, nose shadows, forehead patches, pores-as-noise, mottling, speckles, or asymmetric skin discoloration. "
            "Do NOT add new freckles, moles, or random dots not visible in this reference. "
            "HAIRSTYLE: Match hair length, part, left–right balance, and texture to this reference — "
            "do NOT invent asymmetric cuts, uneven lengths, or a different wave pattern. "
            "The generated model must look like the SAME PERSON in every shot."
        )})
    return parts


# ── Garment length → body position mapping ──────────────────

_MODEL_HEIGHTS = {
    "preset_9": 175, "preset_11": 175, "preset_12": 190, "preset_13": 185,
    "preset_kids_1": 118, "preset_kids_2": 120, "preset_kids_3": 138,
    "preset_kids_7": 140,
    "preset_kids_4": 142, "preset_kids_5": 158, "preset_kids_6": 162,
}

_MODEL_GENDERS = {
    "preset_9": "female", "preset_11": "female", "preset_12": "male", "preset_13": "male",
    "preset_kids_1": "female", "preset_kids_2": "male", "preset_kids_3": "female",
    "preset_kids_7": "female",
    "preset_kids_4": "male", "preset_kids_5": "female", "preset_kids_6": "male",
}

_MODEL_AGE_GROUPS = {
    "preset_9": "adult", "preset_11": "adult", "preset_12": "adult", "preset_13": "adult",
    "preset_kids_1": "kids", "preset_kids_2": "kids", "preset_kids_3": "kids",
    "preset_kids_7": "kids",
    "preset_kids_4": "kids", "preset_kids_5": "kids", "preset_kids_6": "kids",
}

# elem_low=초등 저학년, elem_high=초등 고학년, middle=중학교
_MODEL_KIDS_GRADES = {
    "preset_kids_1": "elem_low", "preset_kids_2": "elem_low",
    "preset_kids_3": "elem_high", "preset_kids_7": "elem_high", "preset_kids_4": "elem_high",
    "preset_kids_5": "middle", "preset_kids_6": "middle",
}

_KIDS_GRADE_BODY_HINTS = {
    "elem_low": (
        "The model is a lower-elementary child (Korean grades 1–3, approximately 7–9 years old, ~118–122cm tall). "
        "Use the smallest child body scale — noticeably shorter than upper-elementary or middle-school models."
    ),
    "elem_high": (
        "The model is an upper-elementary child (Korean grades 4–6, approximately 10–12 years old, ~138–142cm tall). "
        "Use a mid-height child body scale — taller than lower-elementary but clearly shorter than a middle-school teen."
    ),
    "middle": (
        "The model is a middle-school teenager (Korean grades 7–9, approximately 13–15 years old, ~158–162cm tall). "
        "Use a taller youth body scale — clearly taller than elementary children but still not an adult fashion model."
    ),
}

_KIDS_GRADE_HEIGHT_RANGES = {
    "elem_low": (118, 122),
    "elem_high": (138, 142),
    "middle": (158, 162),
}


def _build_kids_height_lock_prompt(kids_grade: Optional[str], model_height_cm: float) -> str:
    if not kids_grade or model_height_cm <= 0:
        return ""
    wrong_grade_hints = {
        "elem_low": "Do NOT render as upper-elementary (~138cm) or middle-school (~158cm) height.",
        "elem_high": (
            "Do NOT render as lower-elementary (~118cm) height — the body must look clearly taller than a 7–9 year old. "
            "If the model looks like grades 1–3 stature, the output is WRONG."
        ),
        "middle": "Do NOT render as elementary-child height (~118–142cm) — the body must look like a middle-school teen.",
    }
    lo, hi = _KIDS_GRADE_HEIGHT_RANGES.get(kids_grade, (model_height_cm - 2, model_height_cm + 2))
    grade_label = {
        "elem_low": "lower-elementary (grades 1–3)",
        "elem_high": "upper-elementary (grades 4–6)",
        "middle": "middle-school (grades 7–9)",
    }.get(kids_grade, kids_grade)
    return (
        f"KIDS HEIGHT LOCK (CRITICAL): This model is {grade_label}. "
        f"Total standing height MUST be approximately {model_height_cm:.0f}cm (acceptable range {lo:.0f}–{hi:.0f}cm). "
        f"{wrong_grade_hints.get(kids_grade, '')} "
        "In the full-body frame, taller grades must occupy more vertical body height than younger grades."
    )

def _resolve_model_kids_grade(model_face, payload_kids_grade=None) -> Optional[str]:
    if payload_kids_grade in _KIDS_GRADE_BODY_HINTS:
        return payload_kids_grade
    if isinstance(model_face, str) and model_face.startswith("preset_"):
        return _MODEL_KIDS_GRADES.get(model_face)
    return None

def _resolve_model_age_group(model_face, payload_age_group=None) -> str:
    if payload_age_group in ("adult", "kids"):
        return payload_age_group
    if isinstance(model_face, str) and model_face.startswith("preset_"):
        return _MODEL_AGE_GROUPS.get(model_face, "adult")
    return "adult"

def _resolve_model_gender(model_face, payload_gender=None) -> str:
    """Resolve model gender: payload override > preset mapping > default."""
    if payload_gender and payload_gender in ("male", "female"):
        return payload_gender
    if isinstance(model_face, str) and model_face.startswith("preset_"):
        return _MODEL_GENDERS.get(model_face, "female")
    return "female"

def _resolve_model_height(model_face, payload_height) -> float:
    try:
        h = float(payload_height) if payload_height else 0
        if h > 0: return h
    except (ValueError, TypeError): pass
    if isinstance(model_face, str) and model_face.startswith("preset_"):
        return _MODEL_HEIGHTS.get(model_face, 170)
    return 170

def _calc_garment_endpoint(model_height_cm: float, garment_length_cm: float, category: str) -> str:
    if model_height_cm <= 0 or garment_length_cm <= 0: return ""
    start_map = {"상의":0.82, "아우터":0.82, "원피스":0.82, "하의":0.57, "스커트":0.57}
    start_ratio = start_map.get(category, 0.57)
    hem = model_height_cm * start_ratio - garment_length_cm
    r = hem / model_height_cm
    if r <= 0.03:   pos = "reaching the floor"
    elif r <= 0.07: pos = "at the ankle bone"
    elif r <= 0.12: pos = "between ankle and mid-calf"
    elif r <= 0.18: pos = "at mid-calf"
    elif r <= 0.23: pos = "just below the knee"
    elif r <= 0.28: pos = "at the knee"
    elif r <= 0.33: pos = "just above the knee"
    elif r <= 0.38: pos = "at mid-thigh"
    elif r <= 0.43: pos = "at upper thigh (short)"
    elif r <= 0.50: pos = "at hip level (very short)"
    elif r <= 0.60: pos = "at the waist"
    elif r <= 0.68: pos = "at the midriff (crop)"
    else:           pos = "above the chest (very cropped)"
    return f"Model {model_height_cm:.0f}cm, garment {garment_length_cm:.0f}cm → hem MUST end {pos} (~{hem:.0f}cm from ground)."

def _build_garment_length_prompts(product_infos: list, model_height_cm: float) -> str:
    if not model_height_cm or model_height_cm <= 0: return ""
    guides = []
    for i, info in enumerate(product_infos):
        try: tl = float(info.get("totalLength", 0))
        except: tl = 0
        if tl > 0:
            g = _calc_garment_endpoint(model_height_cm, tl, info.get("category", ""))
            if g: guides.append(f"[Item {i+1} - {info.get('category','')}] {g}")
    return ("GARMENT LENGTH (CRITICAL):\n" + "\n".join(guides)) if guides else ""


def _build_camera_prompt(
    shot_label: str,
    gender: str = "female",
    age_group: str = "adult",
    model_height_cm: float = 0,
    kids_grade: Optional[str] = None,
) -> str:
    rear_hair_rule = (
        "HAIR (CRITICAL): Keep a masculine back silhouette. Hair should remain short/neutral and must NOT be styled forward over both shoulders."
        if gender == "male"
        else (
            "HAIR (CRITICAL): This is a FEMALE model. The source image shows long hair — you MUST preserve it. "
            "Keep the feminine long-hair silhouette visible from the back. Hair must stay OUTSIDE the hood and fall naturally behind/around shoulders. "
            "Do NOT replace with short or male-style hair. Do NOT tie into a bun/ponytail unless explicitly requested. "
            "The back-of-head hair length and style must match the long hair visible in the source."
        )
    )
    hood_hair_rule = (
        "HAIR (CRITICAL): Keep a masculine short/neutral hairstyle. Hair should stay above or around neck length and must NOT fall long over the shoulders. Keep hair OUTSIDE the hood and do NOT tuck it into the hood."
        if gender == "male"
        else "HAIR (CRITICAL): Keep hair OUTSIDE the hood. Do NOT tuck hair into the hood; let hair fall naturally around/over the shoulders."
    )
    male_full_body_silhouette_rule = (
        "MALE SILHOUETTE CUE (IMPORTANT): Keep the upper torso visually broad and structured. "
        "Hold the chest open with shoulders squared and slightly expanded, and keep the arms a little away from the torso so the shoulder span reads clearly. "
        "Avoid a soft rounded silhouette that makes the shoulders look narrow. "
        "The shoulder line should read almost horizontal and boxy, with a clean right-angled shoulder impression rather than a hanger-like downward slope. "
        if gender == "male"
        else ""
    )
    pocket_hand_rule = (
        "Hand detail (CRITICAL): keep a feminine hand appearance — smooth skin, slender fingers, and soft natural contours. Avoid visible veins, pronounced tendons, or muscular hand definition."
        if gender == "female"
        else "Hand detail (CRITICAL): keep a natural masculine hand appearance with realistic structure and subtle veins/tendons only if naturally visible."
    )
    female_hand_rule = (
        "FEMALE HAND AESTHETIC (CRITICAL): Hands should look refined and feminine — slim fingers, soft knuckles, smooth skin, minimal visible veins/tendons, and no bulky/masculine hand structure."
        if gender == "female"
        else ""
    )
    front_head_scale_rule = (
        "Keep head size natural for a child (head proportionally larger than adult models — roughly 1/6 to 1/7 of total body height). "
        "Do NOT shrink the head to adult 9-head fashion proportions."
        if age_group == "kids"
        else "Keep head size natural for a commercial lookbook (roughly 1/7.8 to 1/8.3 of total visible body height). "
             "Avoid tiny-head fashion-illustration exaggeration and avoid ultra-long-leg distortion. "
             "Keep a balanced torso-to-leg ratio so the natural waist sits near mid-frame."
    )
    kids_height_rule = (
        f" BODY HEIGHT LOCK: {_build_kids_height_lock_prompt(kids_grade, model_height_cm)}"
        if age_group == "kids" and model_height_cm > 0
        else ""
    )
    mapping = {
        "전신(정면)": f"Full body length shot, directly front view, standing straight and looking at the camera. {male_full_body_silhouette_rule}{hood_hair_rule} {_hair_identity_lock(gender)} {female_hand_rule} CANDIDATE CONSISTENCY LOCK (CRITICAL): All independently generated front candidates must use the same catalog camera template and body scale. Use a fixed lookbook camera/framing template — camera height around upper-hip/waist level, neutral perspective, and stable subject distance. {front_head_scale_rule} Keep margins consistent: about 5-6% space above the head and 6-8% below the feet. Maintain the same apparent model height, shoulder width, torso-to-leg ratio, foot scale, and body width in every candidate. Variation is allowed only in tiny hand relaxation or micro posture, never in camera zoom, crop, or body proportions.{kids_height_rule} The entire model from head to toe must be visible.",
        "전신(자유포즈)": f"Full body length shot with a natural walking-inspired lookbook pose. Keep the body mostly front-facing with a subtle 8-12 degree angle and smooth weight transfer. One leg steps forward naturally with a softly bent front knee (never locked straight), while the rear leg stays slightly bent with a gentle heel lift for realistic gait flow. One hand rests naturally in a pocket and the other arm hangs loose with a small natural swing. Shoulders stay relaxed, torso upright with a subtle lean, and expression calm/confident (no smile). The pose should feel candid and effortless, not stiff or mannequin-like. {male_full_body_silhouette_rule}{hood_hair_rule} {female_hand_rule} The entire model from head to toe must be visible.",
        "전신(자유포즈2)": f"Full body length shot, front-facing with a lively editorial pose. The model shifts weight clearly to one leg, creating a natural asymmetric S-curve. One arm is raised with the hand lightly touching the side/back of the head, while the other arm rests naturally near the hip or inside a pocket. The shoulders stay relaxed, torso has a slight lean, and one knee is softly bent for movement. Expression is calm and confident, looking toward the camera with a natural non-smiling look. The pose should feel like a real Korean fashion campaign cut — effortless but dynamic, not stiff. {male_full_body_silhouette_rule}{hood_hair_rule} {female_hand_rule} The entire model from head to toe must be visible.",
        "전신(측면)": f"Full body length shot, 45-degree profile view, highlighting the fit and silhouette from a natural side-front perspective. {male_full_body_silhouette_rule}{hood_hair_rule} {female_hand_rule} The entire model from head to toe must be visible.",
        "전신(측면포즈1)": f"Full body side-inspired lookbook pose with a soft three-quarter angle (about 30-40 degrees). Keep one leg as the weight-bearing leg and let the other leg rest slightly forward with a relaxed bend at the knee. Keep both hands naturally inside the outerwear pockets, shoulders relaxed, and chin slightly lowered for a calm editorial mood. Preserve clean side silhouette readability while keeping the entire body from head to toe visible. {male_full_body_silhouette_rule}{hood_hair_rule} {female_hand_rule}",
        "전신(측면포즈2)": f"Full body winter lookbook pose with a soft side-front angle (about 20-35 degrees). Keep both hands naturally inside the jacket or hoodie pockets, shoulders relaxed, and the head slightly turned toward camera with a calm neutral expression. Use a grounded stance: one leg placed slightly forward and the other supporting weight, with a subtle bend for a natural casual silhouette. Keep the overall mood cozy and effortless, and ensure the entire model from head to toe remains visible. {male_full_body_silhouette_rule}{hood_hair_rule} {female_hand_rule}",
        "전신(후면)": f"Full body back view shot, 180-degree rotation showing the back design, rear fit, and overall silhouette of the outfit. {rear_hair_rule} Keep hair naturally arranged so key garment back details remain visible as much as possible without forcing all hair to the front. The entire model from head to toe must be visible.",
        "상반신": (
            "Upper body medium-wide shot with generous studio margins. Pull the camera back slightly — NOT a tight bust crop. "
            "Frame from the head down to slightly below the waist, with visible background above the head and on both sides of the shoulders. "
            "DO NOT show thighs, knees, or feet."
        ),
        "상반신(자유포즈)": (
            "Upper body medium-wide shot with a slight three-quarter angle and generous studio margins. "
            "The model's body is turned about 20-30 degrees from the camera with one hand placed naturally behind the hip or lower back. "
            "The head turns toward the camera with a calm, neutral expression — NO smiling. "
            "Pull the camera back slightly — NOT a tight bust crop. Frame from the top of the head to slightly below the waist with visible background on all sides. "
            "Focus on the garment's neckline, texture, buttons, and fit. DO NOT show thighs, knees, or feet."
        ),
        "상반신(후면)": (
            f"Upper body back view medium-wide shot with generous studio margins. The model is turned 180 degrees showing the back. "
            f"Pull the camera back slightly — NOT a tight crop. Frame from the top of the head to slightly below the waist with visible background above and beside the body. "
            f"The full head, both shoulders, and upper hip area must be visible. {rear_hair_rule} "
            f"Keep hair naturally arranged so garment back details remain visible as much as possible without forcing all hair to the front. "
            f"Focus on the back design, yoke seam, neckline from behind, shoulder construction, and fabric texture. DO NOT show thighs, knees, or feet."
        ),
        "하반신": "Lower body focused shot. The frame starts just below the chin/neck, cutting off the top of the head. Show the model's body from neck down to the feet including the full outfit, legs, and shoes. The focus should be on the lower garment fit and leg silhouette, but the top garment should also be partially visible for context.",
        "디테일(상의)": (
            "Close-up detail shot of the UPPER GARMENT focused on the chest and neckline area.\n"
            "COMPOSITION (CRITICAL): The garment must be perfectly HORIZONTALLY CENTERED in the frame. "
            "Both shoulder seams must be fully visible inside the frame with approximately EQUAL margin on LEFT and RIGHT — "
            "the crop must NOT be asymmetric, and no shoulder or sleeve edge may be clipped. "
            "The torso is perfectly frontal (zero rotation), with both shoulders at equal height.\n"
            "CROP BOUNDARIES (STRICT): "
            "TOP EDGE must sit at or just below the lower lip — do NOT show mouth, nose, or eyes; only the underside of the chin/jawline may touch the top edge. "
            "BOTTOM EDGE around mid-chest to upper abdomen (roughly between the pectoral line and the bottom of the ribcage). "
            "LEFT/RIGHT EDGE roughly 8–12% padding outside each shoulder seam, symmetric on both sides.\n"
            "FOCUS (CRITICAL): Tack-sharp, even focus across the ENTIRE visible garment surface — fabric weave, knit stitch texture, neckline ribbing, and shoulder seam must all be crisp. "
            "Think f/5.6–f/8 studio lookbook detail cut: no shallow depth-of-field blur, no soft focus, no background bokeh, no motion blur.\n"
            "STYLING LOCK: Do NOT roll sleeves, fold cuffs, or alter sleeve styling; preserve original sleeve length and garment styling exactly. "
            "Do NOT show full body, hands, or full arms/forearms."
        ),
        "디테일(포켓)": f"Close-up detail shot focused on the side pocket area, similar to a lookbook pocket detail cut. Frame around the lower jacket/top hem and upper hip region so the pocket opening and surrounding seam structure are dominant. Pose detail: the hand is naturally inserted into the pocket with only part of the hand visible. {pocket_hand_rule} IMPORTANT: Keep the top untucked exactly as in the source look (NO tucking into bottoms). Preserve the original hem overlap and natural drape above the pocket area. Emphasize pocket opening shape, topstitch lines, hem line, and nearby sleeve cuff detail. Keep the face and full body out of frame.",
        "디테일(신발)": "Close-up detail shot of the SHOES and lower hem area. Focus on the footwear design, shoe material, sole details, and how the bottom garment hem falls around the ankle and shoe. Frame from mid-calf down to the floor.",
    }
    return mapping.get(shot_label, "Full body front facing fashion photograph.")


def _build_default_shoes_prompt(age_group: str = "adult") -> str:
    if age_group == "kids":
        return (
            "SHOES (DEFAULT): Unless the user specifies different footwear, the model MUST be wearing "
            "plain white canvas sneakers — low-top, lace-up, solid all-white (white upper, white laces, white rubber sole). "
            "Clean, simple kids' sneaker style similar to classic white Vans or Keds."
        )
    return (
        "SHOES (DEFAULT): Unless the user specifies different footwear, the model MUST be wearing classic black leather loafers."
    )


def _build_body_proportion_prompt(
    shot_label: str,
    gender: str = "female",
    age_group: str = "adult",
    model_face=None,
    model_height_cm: float = 0,
    kids_grade: Optional[str] = None,
) -> str:
    """Build body proportion guidance based on shot type, gender, and age group."""
    if age_group == "kids":
        grade_hint = _KIDS_GRADE_BODY_HINTS.get(kids_grade or "", (
            "The model is a child. Use natural child body proportions — the head is proportionally larger "
            "relative to the body compared to adult fashion models."
        ))
        height_hint = f" Total body height MUST be approximately {model_height_cm:.0f}cm." if model_height_cm > 0 else ""
        return (
            f"CHILD MODEL BODY (CRITICAL): {grade_hint}{height_hint} "
            "Do NOT apply adult 9-head fashion canon proportions. "
            "Maintain age-appropriate limb length, torso size, and overall stature consistent with the specified school grade."
        )

    ratio_lock = _build_model_ratio_lock(shot_label, gender)
    male_build = (
        "MALE MODEL BUILD (CRITICAL): The model must have broad, well-defined shoulders — "
        "shoulder width should be approximately 2.5 to 3 times the head width, creating a strong V-shaped upper body silhouette. "
        "The chest should be wide and the waist relatively narrow. "
        "Do NOT generate narrow or sloping shoulders. The broad-shouldered frame is essential for a masculine fashion-model look. "
        "MALE TORSO/LEG BALANCE (CRITICAL): Keep a natural commercial male torso length — NOT a fashion-illustration short torso. "
        "Do NOT exaggerate leg length so that the upper body looks stubby. "
        "Waist-to-floor and head-to-waist should feel balanced for a real catalog model, not an elongated runway sketch."
    ) if gender == "male" else ""

    if _is_full_body_shot(shot_label):
        return (
            "BODY PROPORTIONS (CRITICAL): Use a commercial Korean lookbook body — about 7.8–8.2 head heights. "
            "Do NOT use extreme 9-head fashion-illustration proportions with a short torso and ultra-long legs. "
            "NATURAL WAIST LOCK: The anatomical waist / natural waistband line must sit near the vertical middle of the frame "
            "(about 48–52% from the image top). Do NOT push the waist high in the frame. "
            "TORSO LENGTH: Keep a full, natural torso from shoulders to waist — never compress the upper body. "
            "LEG LENGTH: Legs may be slightly elegant, but crotch-to-floor should be about 47–50% of total body height — "
            "NOT clearly dominating the silhouette. Avoid short-torso / long-leg distortion. "
            "Think SPAO / commercial catalog photography of a real model — NOT fashion-sketch anatomy. "
            f"{male_build}"
            "FRAMING GUIDE: The model should fill approximately 86–89% of the frame height, "
            "with a stable margin of ~5–6% above the head and ~6–8% below the feet (avoid large variation between outputs). "
            "CONSISTENCY LOCK: Keep camera distance and perspective stable across generations; "
            "do NOT alternate between short-torso elongated legs and natural mid-waist framing. "
            "Do NOT distort or compress body proportions. "
            f"{ratio_lock}"
        )
    elif _is_upper_body_shot(shot_label):
        return (
            "BODY PROPORTIONS (IMPORTANT): The model must have natural, well-proportioned upper body anatomy "
            "consistent with a commercial lookbook model. Shoulders, neck, and torso should appear elegant but not compressed. "
            f"{male_build}"
            "FRAMING GUIDE: Leave ~5% margin above the head. The frame should end slightly below the waist. "
            "Do NOT distort shoulder width, neck length, or torso proportions. "
            f"{ratio_lock}"
        )
    elif _is_lower_body_shot(shot_label):
        return (
            "BODY PROPORTIONS (IMPORTANT): The legs and lower body must look natural and commercial, "
            "consistent with a balanced 8-head lookbook silhouette — not ultra-elongated runway legs. "
            "Do NOT over-lengthen the legs relative to the implied torso. "
            f"{ratio_lock}"
        )
    return ""


def _is_full_body_shot(shot_label: str) -> bool:
    s = str(shot_label or "")
    return s.startswith("전신") or s.startswith("?꾩떊") or s.startswith("?袁⑸뻿") or "Full body" in s


def _is_upper_body_shot(shot_label: str) -> bool:
    s = str(shot_label or "")
    return s.startswith("상반신") or s.startswith("?곷컲") or s.startswith("?怨룹뺘") or "Upper body" in s


def _build_upper_body_framing_guide() -> str:
    """Loose waist-up framing with visible studio background on all sides."""
    return (
        "UPPER-BODY FRAMING (LOOSE MEDIUM SHOT — NOT TIGHT): Pull the camera back for a relaxed waist-up composition. "
        "Do NOT use a tight bust/portrait crop that fills the frame edge-to-edge. "
        "The model should occupy about 68-75% of frame height with visible soft light-gray studio background above the head, beside the shoulders, and below the waist crop. "
        "Layout guide: top of hair at 10-14% from image top, chin around 32-36%, shoulder line around 42-48%, "
        "waistline around 72-78%, bottom edge at upper hips/waistband around 78-84% — NEVER show mid-thigh or lower. "
        "Horizontal: leave about 15-20% background margin on each side of the shoulders. "
        "Keep this same loose framing scale across all upper-body variants (front, free pose, back)."
    )


def _is_lower_body_shot(shot_label: str) -> bool:
    s = str(shot_label or "")
    return s.startswith("하반신") or s.startswith("?섎컲") or s.startswith("??롮뺘") or "Lower body" in s


def _build_even_studio_lighting_prompt(scope: str = "") -> str:
    label = f" — {scope}" if scope else ""
    return (
        f"LIGHTING (CRITICAL{label}): Use soft, even, low-contrast studio lighting across the entire frame. "
        "Facial lighting must be uniformly filled with minimal left/right falloff. "
        "Do NOT use strong directional key light from one side. Do NOT create cheek shadows, nose shadows, under-eye patches, or asymmetric facial tone. "
        "Keep skin tone smooth and continuous from forehead to jaw on both sides. "
        "Garment lighting may have gentle depth, but the face must read as evenly lit catalog skin — not remapped or relit."
    )


def _build_even_face_lighting_override(scope: str = "") -> str:
    label = f" — {scope}" if scope else ""
    return (
        f"FACE LIGHTING OVERRIDE (CRITICAL{label}): "
        "Render facial skin as a clean, continuous, evenly lit tone with NO mottling, blotches, dappled patches, speckles, stains, or asymmetric shadow. "
        "Match the reference face identity exactly; do NOT re-paint skin texture or add new marks. "
        "A uniform lookbook face tone is more important than visible pores or directional shadow on the face."
    )


def _build_face_skin_lock_prompt(has_face: bool = False) -> str:
    if not has_face:
        return ""
    return (
        "FACE SKIN LOCK (HIGHEST PRIORITY): The face reference image defines the exact skin appearance. "
        "Copy the reference face skin tone and smoothness exactly — one even catalog complexion with no patchy relighting. "
        "Do NOT invent under-eye circles, cheek stains, nose-bridge smudges, forehead blotches, pore noise, or asymmetric shadow. "
        "Do NOT apply fabric-style texture, grain, or wrinkle detail to facial skin. "
        "If the output face skin looks uneven, blotchy, or dirty compared to the reference, the result is WRONG."
    )


def _build_wall_shadow_block() -> str:
    return """WALL SHADOW (MANDATORY):
Add an almost non-existent wall shadow behind the model.
- Wall shadow must stay at near-zero opacity and very broad diffusion.
- Do NOT show a readable human silhouette on the wall (no head/shoulder outline).
- Blend the shadow into the wall tone until it is barely detectable even on close look.
- Keep shadow offset minimal and very close to the body; avoid lateral spread.
- Keep only a near-invisible micro contact shadow under the shoes.
- This is a minimal depth cue only, effectively close to no shadow.
- These wall-shadow rules apply to the BACKGROUND ONLY — do NOT cast readable shadows onto the face."""


_FABRIC_REALISM_SUFFIX = (
    "These fabric texture rules apply to GARMENTS ONLY. "
    "Do NOT add weave, grain, wrinkles, or micro-texture to facial skin."
)


def _build_transform_lighting_prompt(shot_label: str) -> str:
    if _is_upper_body_shot(shot_label):
        return _build_even_studio_lighting_prompt("UPPER-BODY")
    return (
        "LIGHTING: Maintain directional studio lighting from the LEFT side — the left side of the model should be brighter, "
        "with soft natural shadow falloff on the right side. Do NOT flatten the lighting."
    )


def _build_transform_face_lighting_override(shot_label: str) -> str:
    if _is_upper_body_shot(shot_label):
        return _build_even_face_lighting_override("UPPER-BODY")
    return (
        "FACE LIGHTING OVERRIDE: Keep the face clean and evenly filled even under directional lighting. "
        "Do NOT create mottled, blotchy, dappled, patchy, speckled, stained, or uneven shadows on the face. "
        "Facial skin tone must remain continuous and natural, with gentle fill on the shadow side. "
        "For full-body lookbook shots, prioritize a clean even face tone over visible micro skin texture."
    )


def _build_model_ratio_lock(shot_label: str, gender: str = "female") -> str:
    """Common proportion/camera lock to reduce body-ratio drift between cuts."""
    shoulder_rule = (
        "Male shoulder span should remain about 2.5-3.0 head widths with a stable V-shaped torso. "
        if gender == "male"
        else "Female shoulder span should remain about 2.0-2.25 head widths with a slim editorial torso. "
    )
    return (
        "MODEL RATIO LOCK (CRITICAL): Use one fixed commercial lookbook body template across all outputs for this session. "
        "Keep the same apparent model height, head size, shoulder width, neck length, torso length, waist height, hip height, knee height, ankle position, and foot scale from cut to cut. "
        "The head must stay about 1/7.8 to 1/8.3 of total visible body height; never switch to a tiny 9-head fashion-sketch head. "
        "WAIST HEIGHT LOCK: The natural waist / belt line must stay near mid-frame (about 48-52% from image top) across candidates — "
        "do NOT raise the waist and elongate the legs. "
        "Leg length from crotch to floor should remain about 47-50% of total body height, with the knee at a consistent vertical position. "
        f"{shoulder_rule}"
        "Use the same lookbook camera template every time: neutral lens, no wide-angle stretch, camera around waist/upper-hip height, straight verticals, and stable subject distance. "
        "Changing pose, angle, crop, or background may move limbs or change visible area, but must NOT change the model's body scale, head-to-body ratio, leg-to-torso ratio, or overall stature."
    )


def _build_crop_template_lock(shot_label: str, age_group: str = "adult") -> str:
    """Common framing lock so shot variants keep the same visual scale."""
    if _is_full_body_shot(shot_label):
        if age_group == "kids":
            return (
                "FULL-BODY CROP TEMPLATE (CRITICAL): Use a fixed child lookbook framing box. "
                "Keep natural child head-to-body ratio (head proportionally larger than adult fashion models). "
                "Keep 5-6% headroom and 6-8% footroom. The full figure from head to toe must be visible. "
                "Do NOT shrink the head to adult 9-head fashion proportions."
            )
        return (
            "FULL-BODY CROP TEMPLATE (CRITICAL): Use a fixed catalog mannequin box for every independently generated full-body image. "
            "Keep the head top, shoulders, waistline, knees, ankles, and feet at the same approximate vertical scale in every candidate. "
            "Use this fixed vertical layout: top of hair at 5-6% from the image top, shoulder line around 18-22%, "
            "natural waist / waistband around 48-52% (near mid-frame — NEVER raise waist to ~40%), "
            "crotch/shorts rise around 52-56%, knees around 72-76%, soles/feet around 93-94%. "
            "Use this fixed horizontal layout: body centerline at 50% image width, shoulder span about 17-21% of image width for female models and 21-25% for male models, hip span stable, feet centered and not cropped. "
            "The full figure must occupy about 86-89% of image height with 5-6% headroom and 6-8% footroom. "
            "Do NOT zoom in or zoom out between full-body candidates. Do NOT make the head larger, legs longer, feet smaller, or body narrower/wider to create variation. "
            "Do NOT shorten the torso or raise the waistline to create a long-leg look. "
            "For dynamic poses, fit the pose inside the same body-height box instead of changing the camera distance. "
            "A walking/free pose may change limb angles, but the top of hair and soles must stay on the same vertical crop marks."
        )
    if _is_upper_body_shot(shot_label):
        return _build_upper_body_framing_guide()
    if _is_lower_body_shot(shot_label):
        return (
            "LOWER-BODY CROP TEMPLATE (CRITICAL): Keep lower-body magnification consistent with the source model. "
            "Do NOT zoom the legs or feet larger/smaller than the source scale."
        )
    return ""


def _build_transform_crop_box_lock(shot_label: str) -> str:
    """Extra crop lock for transform shots derived from a selected reference cut."""
    s = str(shot_label or "")
    applies_full_body = (
        s.startswith("전신(자유포즈")
        or s.startswith("전신(측면")
        or s.startswith("전신(후면")
        or s.startswith("?꾩떊(?먯쑀")
        or s.startswith("?꾩떊(痢")
        or s.startswith("?꾩떊(?꾨")
    )
    applies_upper_body = (
        s == "상반신"
        or s.startswith("상반신(자유포즈")
        or s == "?곷컲"
        or s.startswith("?곷컲")
        or s == "?怨룹뺘"
        or s.startswith("?怨룹뺘")
    )
    if applies_full_body:
        extra = (
            "For the full-body free-pose-2 variant specifically, do not enlarge the upper body or face to emphasize the raised-arm pose; keep it the same size as the selected front image. "
            if s.startswith("전신(자유포즈2") or "자유포즈2" in s
            else ""
        )
        return (
            "SELECTED FRONT CROP BOX LOCK (HIGHEST PRIORITY FOR THIS TRANSFORM): "
            "Use the first image's full-body crop box as the fixed camera frame. "
            "Do not recompose, zoom in, zoom out, change camera distance, or change lens perspective. "
            "Keep the top of hair and soles/feet at the same approximate pixel positions as the first image. "
            "Keep shoulder line, waistline, hip line, knee height, ankle height, body centerline, and foot scale aligned to the first image as much as the new pose/angle allows. "
            "Only pose/angle may change. The model's apparent height, head size, leg-to-torso ratio, body width, and overall scale must remain the same. "
            "Fit the new pose inside the existing crop box instead of changing the crop box. "
            f"{extra}"
        )
    if applies_upper_body:
        return (
            "SELECTED UPPER-BODY CROP BOX LOCK (HIGHEST PRIORITY FOR THIS TRANSFORM): "
            "Use one fixed loose upper-body crop box for upper-body front, free-pose, and back shots. "
            "Do not recompose to a tighter face crop, zoom in, or fill the frame edge-to-edge with the body. "
            "Keep top-of-hair, face size, chin height, shoulder line, torso width, waistline, and bottom crop at the same approximate pixel positions across upper-body shots. "
            + _build_upper_body_framing_guide()
            + " The free pose may rotate shoulders/torso or move one arm, but must stay inside the same loose crop box."
        )
    return ""


def _build_transform_instruction(shot_label: str, gender: str = "female") -> str:
    """PICasso-style: transform the front shot into a different angle/crop."""
    rear_hair_rule = (
        "HAIR (CRITICAL): Keep a masculine back silhouette. Hair should remain short/neutral and must NOT be styled forward over both shoulders. "
        if gender == "male"
        else (
            "HAIR (CRITICAL): This is a FEMALE model. The source image shows long hair — you MUST preserve it in the back view. "
            "Keep the feminine long-hair silhouette visible from behind. Hair must stay OUTSIDE the hood and fall naturally behind/around shoulders. "
            "Do NOT replace with short or male-style hair. Do NOT tie into a bun/ponytail unless explicitly requested. "
            "The back-of-head hair length and style must match the long hair visible in the source."
        )
    )
    hood_hair_rule = (
        "HAIR (CRITICAL): Keep a masculine short/neutral hairstyle. Hair should stay above or around neck length and must NOT fall long over the shoulders. Keep hair OUTSIDE the hood and do NOT tuck it into the hood. "
        if gender == "male"
        else "HAIR (CRITICAL): Keep hair OUTSIDE the hood. Do NOT tuck hair into the hood; let hair fall naturally around/over the shoulders. "
    )
    male_full_body_silhouette_rule = (
        "MALE SILHOUETTE CUE (IMPORTANT): Keep the upper torso visually broad and structured. "
        "Hold the chest open with shoulders squared and slightly expanded, and keep the arms a little away from the torso so the shoulder span reads clearly. "
        "Avoid a soft rounded silhouette that makes the shoulders look narrow. "
        "The shoulder line should read almost horizontal and boxy, with a clean right-angled shoulder impression rather than a hanger-like downward slope. "
        if gender == "male"
        else ""
    )
    pocket_hand_rule = (
        "Hand detail (CRITICAL): keep a feminine hand appearance — smooth skin, slender fingers, and soft natural contours. Avoid visible veins, pronounced tendons, or muscular hand definition. "
        if gender == "female"
        else "Hand detail (CRITICAL): keep a natural masculine hand appearance with realistic structure and subtle veins/tendons only if naturally visible. "
    )
    female_hand_rule = (
        "FEMALE HAND AESTHETIC (CRITICAL): Hands should look refined and feminine — slim fingers, soft knuckles, smooth skin, minimal visible veins/tendons, and no bulky/masculine hand structure. "
        if gender == "female"
        else ""
    )
    hair_front_lock = (
        "HAIR LOCK (ABSOLUTE): Match reference hair exactly — same cut length and side volume; "
        "do NOT randomize thickness or asymmetric styling not shown in the reference."
        if gender == "male"
        else (
            "HAIR LOCK (ABSOLUTE — non-negotiable): Copy hair EXACTLY from the reference — same visible length on LEFT and RIGHT, "
            "same part line, same texture (straight/wavy) and volume. Do NOT asymmetrically shorten one side, do NOT change wave pattern, "
            "do NOT restyle or thin one side only. Long hair must fall symmetrically past the shoulders as in the reference."
        )
    )
    mapping = {
        "전신(정면)": (
            "Re-render this full-body FRONT studio fashion photo using the reference image as the ground truth. "
            "BODY / HEIGHT LOCK (NON-NEGOTIABLE): Keep the EXACT same body height, child age scale, head-to-body ratio, "
            "crop magnification, and vertical footprint as the reference. Do NOT shrink the model to a younger/shorter grade. "
            "Permitted micro-variations ONLY: subtle lighting balance on face and clothing, tiny shoulder relaxation, neutral hand placement, "
            "or a sub-2° stance shift — remain a straight-on full-body lookbook framing. "
            f"{hair_front_lock} "
            "Keep the EXACT same model identity, face, hair, outfit, shoes, and soft light-gray studio background. "
            "The entire model from head to toe must remain visible."
        ),
        "전신(자유포즈)": (
            "Transform this front-facing full body shot into a NATURAL WALKING-INSPIRED LOOKBOOK POSE. "
            "Keep the body mostly front-facing with a subtle 8-12 degree angle and smooth dynamic weight transfer. "
            "Place one leg stepping forward with a softly bent front knee (never straight-locked), and keep the rear leg slightly bent with a light heel lift. "
            "Set one hand naturally in a pocket and keep the other arm relaxed with a small natural swing. "
            "Keep shoulders soft, torso mostly upright with a subtle natural lean, and expression calm/confident (NO smiling). "
            "The pose should feel candid, effortless, and alive — NOT rigid, NOT mannequin-like, and NOT frozen. "
            f"{male_full_body_silhouette_rule}"
            f"{female_hand_rule}"
            f"{hood_hair_rule}"
            "Keep the EXACT same model, face, hair, outfit, shoes, and soft light-gray studio background. "
            "The entire model from head to toe must remain visible."
        ),
        "전신(자유포즈2)": (
            "Transform this front-facing full body shot into a DYNAMIC NATURAL FASHION POSE. "
            "CAMERA / SCALE LOCK (NON-NEGOTIABLE): Keep the exact same camera distance, crop box, body height in frame, head size, shoulder width, waist height, leg length, and foot scale as the source front shot. Do NOT enlarge the model to make the pose feel more dynamic. "
            "Shift body weight clearly onto one leg to create a natural asymmetric S-curve and subtle hip tilt. "
            "Raise one arm gently near the side of the head (near temple/ear) while keeping the elbow compact and inside the original body-width frame; do not lift the arm so high that the crop needs to zoom out or the upper body becomes larger. Keep the other arm relaxed near the hip or inside a pocket. "
            "Keep the shoulders soft, add a slight torso lean, and let one knee bend naturally for movement. "
            "The expression is calm, cool, and neutral — looking toward the camera with understated confidence. NO smiling. "
            "The pose should feel like a Korean lookbook campaign cut — effortless but alive, never mannequin-like. "
            f"{male_full_body_silhouette_rule}"
            f"{female_hand_rule}"
            f"{hood_hair_rule}"
            "Keep the EXACT same model, face, hair, outfit, shoes, and soft light-gray studio background. "
            "The entire model from head to toe must remain visible."
        ),
        "전신(측면)": (
            "Transform this front-facing full body shot into a SLIGHT ANGLED VIEW. "
            "Rotate the model's BODY approximately 15–25 degrees to create a subtle side-front silhouette while keeping the FACE looking directly at the camera. "
            f"{male_full_body_silhouette_rule}"
            f"{female_hand_rule}"
            f"{hood_hair_rule}"
            "Keep the EXACT same model, face, hair, outfit, shoes, and soft light-gray studio background. "
            "The entire model from head to toe must remain visible."
        ),
        "전신(측면포즈1)": (
            "Transform this front-facing full body shot into a SIDE-INSPIRED THREE-QUARTER LOOKBOOK POSE. "
            "Rotate the body to about 30-40 degrees. Keep one leg as the weight-bearing leg and place the other leg slightly forward with a soft natural bend. "
            "Place both hands naturally inside outerwear pockets, keep shoulders relaxed, and keep the mood calm/editorial. "
            f"{male_full_body_silhouette_rule}"
            f"{female_hand_rule}"
            f"{hood_hair_rule}"
            "Keep the EXACT same model, face, hair, outfit, shoes, and soft light-gray studio background. "
            "The entire model from head to toe must remain visible."
        ),
        "전신(측면포즈2)": (
            "Transform this front-facing full body shot into a WINTER LOOKBOOK SIDE-FRONT POSE inspired by a casual cozy campaign cut. "
            "Rotate the body to about 20-35 degrees side-front angle (NOT full side profile). "
            "Place both hands naturally into jacket/hoodie pockets, keep shoulders relaxed, and keep the head slightly turned toward camera with a calm neutral expression. "
            "Set a grounded stance with one leg slightly forward and the other weight-bearing, with a subtle natural knee bend. "
            f"{male_full_body_silhouette_rule}"
            f"{female_hand_rule}"
            f"{hood_hair_rule}"
            "Keep the EXACT same model, face, hair, outfit, shoes, and soft light-gray studio background. "
            "The entire model from head to toe must remain visible."
        ),
        "전신(후면)": (
            "Transform this front-facing full body shot into a BACK VIEW. "
            "Rotate the model 180 degrees to show the back of the exact same outfit. "
            f"{rear_hair_rule}"
            "Keep hair naturally arranged so key garment back details remain visible as much as possible, without forcing all hair forward. "
            "Keep the same body type, hair style, shoes, and soft light-gray studio background. "
            "Show the back details of the garment clearly. Head to toe must be visible."
        ),
        "상반신": (
            "CROP AND REFRAME this image into an UPPER BODY ONLY shot with a LOOSE medium-wide composition. "
            "ZOOM OUT slightly — do NOT create a tight bust/portrait crop. Leave visible studio background above the head, on both sides of the shoulders, and below the waist crop line. "
            "The output image MUST show ONLY the upper body — from the full head and hair to slightly below the waistline, revealing the upper hips and waistband. "
            "ABSOLUTELY NO thighs, knees, shins, or feet should be visible in the output. If legs are visible, the crop is WRONG. "
            "Pose direction (IMPORTANT): keep the model mostly front-facing with a subtle natural asymmetry — one shoulder slightly lowered, torso turned about 5-10 degrees, and both arms relaxed naturally at the sides. "
            "Keep the expression calm and effortless, avoiding stiff mannequin posture. "
            "Focus on the fit, texture, and silhouette of the top garment while keeping natural facial detail. "
            "Keep the same body type, hair style, and soft light-gray studio background."
        ),
        "상반신(자유포즈)": (
            "CROP AND REFRAME this full body image into an UPPER BODY ONLY shot with a LOOSE medium-wide composition. "
            "ZOOM OUT slightly — do NOT create a tight bust/portrait crop. Leave visible studio background above the head, on both sides of the shoulders, and below the waist crop line. "
            "The output image MUST show ONLY the upper body — from the top of the head to slightly below the waist. "
            "ABSOLUTELY NO thighs, knees, shins, or feet should be visible in the output. If legs are visible, the crop is WRONG. "
            "The model's body is turned about 20-30 degrees from the camera, with one hand placed naturally behind the hip or lower back. "
            "The head turns toward the camera with a calm, cool, neutral expression — NO smiling. "
            "Focus on the garment's neckline, fabric texture, buttons, and overall fit. "
            "Keep the EXACT same model, face, hair, outfit, and soft light-gray studio background."
        ),
        "상반신(후면)": (
            "CROP AND REFRAME this image into an UPPER BODY BACK VIEW ONLY with a LOOSE medium-wide composition. "
            "ZOOM OUT slightly — do NOT create a tight back crop. Leave visible studio background above the head, on both sides of the shoulders, and below the waist crop line. "
            "The output image MUST show ONLY the upper body from behind — from the top of the head to slightly below the waist. "
            "ABSOLUTELY NO thighs, knees, shins, or feet should be visible in the output. If legs are visible, the crop is WRONG. "
            "Rotate the model 180 degrees to show the back of the upper garment. "
            f"{rear_hair_rule}"
            "Keep hair naturally arranged so key upper-garment back details remain visible as much as possible, without forcing all hair forward. "
            "Focus on the back design, yoke seam, shoulder construction, and fabric texture. "
            "Keep the EXACT same model, hair, outfit, and soft light-gray studio background."
        ),
        "하반신": (
            "Transform this image into a LOWER BODY CLOSE-UP. "
            "Frame the camera to focus on the model from the waistline or belt line down to the feet. "
            "Compose the shot so the lower garment and leg silhouette dominate the frame. "
            "Keep the same body type, hair style, shoes, and soft light-gray studio background. "
            "Do not show the chest, shoulders, or face. "
        ),
        "디테일(상의)": (
            "Transform this image into a CLOSE-UP DETAIL SHOT of the UPPER GARMENT focused on the chest and neckline area.\n"
            "COMPOSITION (CRITICAL): The garment must be perfectly HORIZONTALLY CENTERED in the new crop. "
            "Both shoulder seams must be fully visible inside the frame with approximately EQUAL margin on LEFT and RIGHT — "
            "no asymmetric crop, and no shoulder or sleeve edge may be clipped off either side. "
            "Keep torso perfectly frontal (zero rotation), both shoulders at equal height.\n"
            "CROP BOUNDARIES (STRICT): "
            "TOP EDGE at or just below the lower lip — do NOT show mouth, nose, or eyes; only the underside of the chin/jawline may touch the top edge. "
            "BOTTOM EDGE around mid-chest to upper abdomen (between the pectoral line and the bottom of the ribcage). "
            "LEFT/RIGHT EDGE roughly 8–12% padding outside each shoulder seam, symmetric on both sides.\n"
            "FOCUS (CRITICAL): Tack-sharp, even focus across the ENTIRE visible garment surface — fabric weave, knit stitch, neckline ribbing, and shoulder seam must all be crisp. "
            "f/5.6–f/8 studio lookbook detail cut: no shallow DOF blur, no soft focus, no bokeh, no motion blur.\n"
            "STYLING LOCK (CRITICAL): Do NOT roll sleeves, fold cuffs, or alter sleeve styling; keep original sleeve length and garment styling exactly. "
            "Do NOT show hands, full arms/forearms, or the full body. "
            "Keep the same soft light-gray studio background."
        ),
        "디테일(포켓)": (
            "Transform this image into a CLOSE-UP DETAIL SHOT centered on the side POCKET area, matching a lookbook pocket composition. "
            "Crop and reframe around lower top/jacket hem to upper-hip region so the pocket opening is the visual center. "
            "Pose detail (IMPORTANT): place the hand naturally inside the pocket, showing only a partial hand emerging from the opening. "
            f"{pocket_hand_rule}"
            "Styling lock (CRITICAL): keep the top untucked exactly as-is; DO NOT tuck the top into the bottoms. Preserve natural hem overlap and drape above the pocket. "
            "Highlight pocket opening curve, seam lines, edge stitching, hem line, and adjacent sleeve cuff/fabric texture. "
            "Do NOT show face or full body; keep the crop tight and product-detail focused. "
            "Keep the same soft light-gray studio background. "
        ),
        "디테일(신발)": (
            "Transform this image into a CLOSE-UP DETAIL SHOT of the SHOES and LOWER HEM. "
            "Crop and reframe to focus on the footwear, shoe material, sole, and how the garment hem falls around the ankle. "
            "Frame from mid-calf down to the floor. "
            "Keep the same soft light-gray studio background. "
        ),
    }
    return mapping.get(shot_label, "")


def _build_sub_accessory_prompt(
    sub_product_slots: list,
    sub_product_images: list,
    accessory_image_start: int = 1,
) -> tuple[str, bool]:
    """Build accessory instructions for sub-product reference images (제품 1·2 악세사리)."""
    if not sub_product_images or not sub_product_slots:
        return "", False

    lines = []
    image_idx = accessory_image_start
    for slot in sub_product_slots:
        if not isinstance(slot, dict):
            continue
        product_index = int(slot.get("productIndex", 0)) + 1
        image_count = int(slot.get("imageCount", 1))
        if image_count > 1:
            image_ref = f"Images {image_idx}–{image_idx + image_count - 1}"
        else:
            image_ref = f"Image {image_idx}"
        view_desc = "front view" + (" and back view" if image_count > 1 else "")
        lines.append(
            f"- {image_ref}: Accessory for Clothing Item {product_index} (main garment #{product_index}). "
            f"Copy this accessory from the reference ({view_desc}) and wear/style it naturally with Clothing Item {product_index}. "
            f"The accessory MUST be visible in the final photo — do NOT omit it."
        )
        image_idx += image_count

    if not lines:
        return "", False

    accessory_block = (
        "ACCESSORIES (CRITICAL — MUST APPEAR IN OUTPUT):\n"
        + "\n".join(lines)
        + "\n- The model wears the main garments PLUS these accessory items. Accessories are add-ons to Clothing Items 1–2, not replacements.\n"
        + "- Reproduce each accessory's shape, color, material, and placement as shown in its reference image(s).\n"
        + "- Do NOT add any other accessories beyond those reference images."
    )
    return accessory_block, True


def _build_input_image_order_guide(
    has_face: bool,
    has_tone_ref: bool,
    product_image_count: int,
    sub_product_slots: list,
    sub_product_images: list,
    has_background: bool,
) -> str:
    idx = 1
    lines = ["INPUT IMAGE ORDER (CRITICAL — use the correct reference for each task):"]
    if has_face:
        lines.append(f"- Image {idx}: FACE reference (model identity only)")
        idx += 1
    if has_tone_ref:
        lines.append(f"- Image {idx}: TONE palette strip (color grade only — NOT clothing or background)")
        idx += 1
    if product_image_count:
        end = idx + product_image_count - 1
        range_label = f"Images {idx}–{end}" if product_image_count > 1 else f"Image {idx}"
        lines.append(f"- {range_label}: MAIN GARMENT product photos (wear these exact garments)")
        idx += product_image_count
    if sub_product_images and sub_product_slots:
        for slot in sub_product_slots:
            if not isinstance(slot, dict):
                continue
            product_index = int(slot.get("productIndex", 0)) + 1
            image_count = int(slot.get("imageCount", 1))
            end = idx + image_count - 1
            range_label = f"Images {idx}–{end}" if image_count > 1 else f"Image {idx}"
            lines.append(f"- {range_label}: ACCESSORY for Clothing Item {product_index}")
            idx += image_count
    if has_background:
        lines.append(f"- Image {idx}: BACKGROUND plate")
    return "\n".join(lines)


def _build_single_image_composition_lock() -> str:
    """Prevent collage/grid/contact-sheet outputs when users ask for variety."""
    return (
        "SINGLE-IMAGE COMPOSITION (CRITICAL — HIGHEST PRIORITY):\n"
        "- Output exactly ONE full-frame photograph.\n"
        "- Show exactly ONE person, ONE pose, ONE expression, ONE camera angle.\n"
        "- Do NOT create a grid, collage, contact sheet, split-screen, triptych, panel layout, "
        "storyboard, or multiple thumbnail crops of the same model in one image.\n"
        "- Do NOT duplicate the model or show before/after / variation / expression panels.\n"
        "- The entire canvas must read as a single commercial fashion photo, not a compilation."
    )


def _sanitize_custom_prompt_for_single_image(raw: str) -> str:
    """Reframe prompts that often make the model output multi-panel layouts."""
    if not raw or not str(raw).strip():
        return ""

    text = str(raw).strip()
    single_expression_note = (
        "Use ONE subtle natural expression in this single photograph "
        "(do NOT show multiple expressions or faces in one image)"
    )
    single_shot_note = (
        "Generate one single shot only "
        "(do NOT put multiple cuts/poses in one frame)"
    )

    replacements = [
        (r"표정\s*(이\s*)?(다양|여러|다르)[^\./,]*", single_expression_note),
        (r"다양한\s*표정[^\./,]*", single_expression_note),
        (r"여러\s*(가지\s*)?표정[^\./,]*", single_expression_note),
        (r"(diverse|various|multiple|different)\s+expressions?[^\./,]*", single_expression_note),
        (r"expression\s+(variety|variations?|options?)[^\./,]*", single_expression_note),
        (r"여러\s*컷[^\./,]*", single_shot_note),
        (r"여러\s*장[^\./,]*", single_shot_note),
        (r"한\s*장\s*안\s*에\s*여러[^\./,]*", single_shot_note),
        (r"(multiple|several|various)\s+(shots?|cuts?|poses?|looks?|frames?)[^\./,]*", single_shot_note),
        (r"contact\s*sheet[^\./,]*", single_shot_note),
        (r"grid\s*layout[^\./,]*", single_shot_note),
        (r"collage[^\./,]*", single_shot_note),
    ]

    sanitized = text
    for pattern, repl in replacements:
        sanitized = re.sub(pattern, repl, sanitized, flags=re.IGNORECASE)

    sanitized = re.sub(r"\s*/\s*/+", " / ", sanitized)
    sanitized = re.sub(r"\s{2,}", " ", sanitized).strip(" /")
    return sanitized.strip()


# ── HF Dataset Push (background) ─────────────────────────────

def _make_jpg_thumb(img_bytes: bytes, max_side: int = 512) -> BytesIO:
    try:
        from PIL import Image
        im = Image.open(BytesIO(img_bytes)).convert("RGB")
        im.thumbnail((max_side, max_side), Image.LANCZOS)
        bio = BytesIO()
        im.save(bio, "JPEG", quality=85)
        bio.seek(0)
        return bio
    except Exception:
        return BytesIO(img_bytes)


def _sanitize_upload_filename(name: Optional[str], fallback: str) -> str:
    """Keep a readable original basename while making it safe for HF paths."""
    raw = (name or "").strip()
    base = os.path.basename(raw.replace("\\", "/")) if raw else ""
    if not base or base in (".", ".."):
        base = fallback
    # Keep letters/digits/._- and common unicode letters; collapse everything else.
    cleaned = re.sub(r"[^\w.\-가-힣]+", "_", base, flags=re.UNICODE)
    cleaned = cleaned.strip("._") or fallback
    # Avoid path traversal leftovers and overly long names.
    cleaned = cleaned.replace("..", "_")
    if len(cleaned) > 120:
        stem, ext = os.path.splitext(cleaned)
        cleaned = stem[:100] + ext[:20]
    return cleaned


def _unique_filenames(names: list, fallback_prefix: str) -> list:
    used = set()
    out = []
    for i, name in enumerate(names):
        safe = _sanitize_upload_filename(name, f"{fallback_prefix}_{i}")
        stem, ext = os.path.splitext(safe)
        candidate = safe
        n = 2
        while candidate.lower() in used:
            candidate = f"{stem}_{n}{ext}"
            n += 1
        used.add(candidate.lower())
        out.append(candidate)
    return out


def _push_to_hub_sync(record: dict, image_bytes: bytes, ts: str,
                      product_images_b64: list = None, face_b64: str = None,
                      product_filenames: list = None, face_filename: str = None,
                      sub_product_images_b64: list = None, sub_product_filenames: list = None):
    """Run in background thread — never blocks the API response."""
    if not HF_TOKEN or not HF_DATASET_REPO:
        return
    try:
        from huggingface_hub import HfApi, CommitOperationAdd
    except ImportError:
        return

    api = HfApi(token=HF_TOKEN)
    try:
        api.create_repo(HF_DATASET_REPO, repo_type="dataset", exist_ok=True)
    except Exception:
        pass

    day = ts[:8]
    ops = []
    filename = f"fashion_{ts}_0.jpg"
    ops.append(CommitOperationAdd(path_in_repo=f"images/{day}/{filename}", path_or_fileobj=BytesIO(image_bytes)))
    ops.append(CommitOperationAdd(path_in_repo=f"thumbs/{day}/{filename}", path_or_fileobj=_make_jpg_thumb(image_bytes)))

    saved_product_paths = []
    if product_images_b64:
        names = list(product_filenames or [])
        while len(names) < len(product_images_b64):
            names.append(f"product_{len(names)}.png")
        names = _unique_filenames(names[:len(product_images_b64)], "product")
        print(f"[HUB] Product filenames: {names}")
        for i, img_b64 in enumerate(product_images_b64):
            try:
                if isinstance(img_b64, str) and ";base64," in img_b64:
                    raw = base64.b64decode(img_b64.split(";base64,", 1)[1])
                    # Keep original filename; nest under ts folder so gallery index can match.
                    path = f"inputs/product/{day}/{ts}/{names[i]}"
                    ops.append(CommitOperationAdd(path_in_repo=path, path_or_fileobj=BytesIO(raw)))
                    saved_product_paths.append(path)
            except Exception:
                pass

    saved_sub_paths = []
    if sub_product_images_b64:
        names = list(sub_product_filenames or [])
        while len(names) < len(sub_product_images_b64):
            names.append(f"sub_product_{len(names)}.png")
        names = _unique_filenames(names[:len(sub_product_images_b64)], "sub_product")
        for i, img_b64 in enumerate(sub_product_images_b64):
            try:
                if isinstance(img_b64, str) and ";base64," in img_b64:
                    raw = base64.b64decode(img_b64.split(";base64,", 1)[1])
                    path = f"inputs/product/{day}/{ts}/sub/{names[i]}"
                    ops.append(CommitOperationAdd(path_in_repo=path, path_or_fileobj=BytesIO(raw)))
                    saved_sub_paths.append(path)
            except Exception:
                pass

    saved_persona_path = None
    if face_b64:
        try:
            raw_face = base64.b64decode(face_b64)
            persona_name = _sanitize_upload_filename(face_filename, f"persona_{ts}.jpg")
            # Ensure persona filename still contains ts for gallery matching when user name lacks it.
            if ts not in persona_name:
                stem, ext = os.path.splitext(persona_name)
                persona_name = f"{stem}_{ts}{ext or '.jpg'}"
            path = f"inputs/persona/{day}/{persona_name}"
            ops.append(CommitOperationAdd(path_in_repo=path, path_or_fileobj=BytesIO(raw_face)))
            saved_persona_path = path
        except Exception:
            pass

    record = dict(record or {})
    if saved_product_paths:
        record["product_input_paths"] = saved_product_paths
        record["product_input_filenames"] = [os.path.basename(p) for p in saved_product_paths]
    if saved_sub_paths:
        record["sub_product_input_paths"] = saved_sub_paths
        record["sub_product_input_filenames"] = [os.path.basename(p) for p in saved_sub_paths]
    if saved_persona_path:
        record["persona_input_path"] = saved_persona_path
        record["persona_input_filename"] = os.path.basename(saved_persona_path)

    ops.append(CommitOperationAdd(
        path_in_repo=f"logs/{day}/fashion_{ts}.json",
        path_or_fileobj=BytesIO(json.dumps(record, ensure_ascii=False, indent=2).encode("utf-8"))
    ))

    try:
        api.create_commit(repo_id=HF_DATASET_REPO, repo_type="dataset", operations=ops,
                          commit_message=f"Add lookbook generation: {ts}")
        print(f"[HUB] ✅ Pushed to {HF_DATASET_REPO} ({len(ops)} files)")
    except Exception as e:
        print(f"[HUB] Upload error: {e}")


def _push_to_hub_background(
    record, image_bytes, ts,
    product_images_b64=None, face_b64=None,
    product_filenames=None, face_filename=None,
    sub_product_images_b64=None, sub_product_filenames=None,
):
    """Fire-and-forget: push to hub in a background thread."""
    t = threading.Thread(
        target=_push_to_hub_sync,
        args=(record, image_bytes, ts),
        kwargs={
            "product_images_b64": product_images_b64,
            "face_b64": face_b64,
            "product_filenames": product_filenames,
            "face_filename": face_filename,
            "sub_product_images_b64": sub_product_images_b64,
            "sub_product_filenames": sub_product_filenames,
        },
        daemon=True,
    )
    t.start()
    print(f"[HUB] Background upload started for {ts}")


# ══════════════════════════════════════════════════════════════
# API: Generate fitting image
# ══════════════════════════════════════════════════════════════

@app.post("/api/generate")
async def api_generate(request: Request, payload: Dict = Body(...)):
    client = get_openai_client()
    current_user = get_current_user(request)
    require_login(current_user)

    product_images = payload.get("productImages", [])
    product_image_filenames = payload.get("productImageFilenames", [])
    sub_product_images = payload.get("subProductImages", [])
    sub_product_image_filenames = payload.get("subProductImageFilenames", [])
    sub_product_slots = payload.get("subProductSlots", [])
    model_face = payload.get("modelFace")
    model_face_filename = payload.get("modelFaceFilename")
    product_infos = payload.get("productInfos", [])
    shot_label = payload.get("shotLabel", "전신(정면)")
    custom_prompt = payload.get("customPrompt", "")
    sanitized_custom_prompt = _sanitize_custom_prompt_for_single_image(custom_prompt)
    single_image_lock = _build_single_image_composition_lock()
    if sanitized_custom_prompt != (custom_prompt or "").strip():
        print(f"[GENERATE] Custom prompt sanitized for single-image output")
    reference_image = payload.get("referenceImage")
    background_image = payload.get("backgroundImage")
    tone_reference_image = payload.get("toneReferenceImage")
    image_size = _resolve_image_size(payload.get("imageSize"))

    model_height_cm = _resolve_model_height(model_face, payload.get("modelHeight", 0))
    model_gender = _resolve_model_gender(model_face, payload.get("modelGender"))
    model_age_group = _resolve_model_age_group(model_face, payload.get("modelAgeGroup"))
    model_kids_grade = _resolve_model_kids_grade(model_face, payload.get("modelKidsGrade"))
    garment_length_guide = _build_garment_length_prompts(product_infos, model_height_cm)

    has_reference = bool(
        reference_image
        and isinstance(reference_image, str)
        and reference_image.startswith("data:")
    )
    has_background = bool(
        background_image
        and isinstance(background_image, str)
        and background_image.startswith("data:")
    )

    request_started_at = datetime.datetime.now()
    request_ts = request_started_at.strftime("%Y-%m-%d %H:%M:%S")
    print(f"[GENERATE] [{request_ts}] User: {current_user['username']}{' (logged in)' if current_user['is_logged_in'] else ' (anon)'} | Shot: {shot_label} | Reference: {'YES' if has_reference else 'NO'} | Background: {'YES' if has_background else 'NO'} | ToneRef: {'YES' if tone_reference_image else 'NO'}")
    wall_shadow_block = _build_wall_shadow_block()

    # ──────────────────────────────────────────────────────────
    # MODE A: TRANSFORM (reference exists → PICasso pattern)
    # ──────────────────────────────────────────────────────────
    if has_reference:
        transform_instruction = _build_transform_instruction(shot_label, model_gender)
        if not transform_instruction:
            camera_angle = _build_camera_prompt(shot_label, model_gender, model_age_group, model_height_cm, model_kids_grade)
            transform_instruction = (
                f"Transform this full body fashion photo. Change the angle/framing to: {camera_angle}. "
                f"Keep the EXACT same model, face, outfit, shoes, and soft light-gray studio background"
            )

        parts = []
        parsed_ref = _parse_base64(reference_image)
        ref_size_kb = len(parsed_ref["data"]) * 3 / 4 / 1024
        print(f"[GENERATE] → TRANSFORM MODE | ref ~{ref_size_kb:.0f} KB")
        parts.append({"inline_data": {"data": parsed_ref["data"], "mime_type": parsed_ref["mime_type"]}})
        if has_background:
            parsed_bg = _parse_base64(background_image)
            bg_size_kb = len(parsed_bg["data"]) * 3 / 4 / 1024
            print(f"[GENERATE] → Background ref ~{bg_size_kb:.0f} KB")
            parts.append({"inline_data": {"data": parsed_bg["data"], "mime_type": parsed_bg["mime_type"]}})

        user_tone_part, user_tone_palette_text = _parse_user_tone_reference(tone_reference_image) if tone_reference_image else (None, "")
        has_user_tone_ref = bool(user_tone_palette_text)
        if has_user_tone_ref:
            print("[GENERATE] → User tone palette text attached (TRANSFORM, image omitted)")

        transform_crop_box_lock = _build_transform_crop_box_lock(shot_label)
        body_proportion_guide = _build_body_proportion_prompt(
            shot_label, model_gender, model_age_group, model_face, model_height_cm, model_kids_grade
        )
        crop_template_guide = _build_crop_template_lock(shot_label, model_age_group)
        background_constraint = (
            "Use the LAST provided image as the target background plate. Keep that background's tone, floor line, perspective depth, and scene structure, and place the SAME model naturally into it. Change background only."
            if has_background
            else "Keep a soft light-gray studio background (subtle warm gray, NOT pure white). No heavy gradients, no props. A gentle tonal shift between wall and floor is acceptable for natural depth."
        )
        background_reference_note = (
            "BACKGROUND REFERENCE (CRITICAL): The LAST image is the background plate. Use it as the target environment and preserve its visual layout."
            if has_background else ""
        )
        tone_transform_note = _build_tone_reference_instruction(
            mode="transform", palette_text=user_tone_palette_text
        ) if has_user_tone_ref else ""

        accessory_transform_note = ""
        _, has_sub_accessories_transform = _build_sub_accessory_prompt(sub_product_slots, sub_product_images, 0)
        if has_sub_accessories_transform:
            accessory_start = 2 + (1 if has_background else 0)
            for img_b64 in sub_product_images:
                if isinstance(img_b64, str) and img_b64.startswith("data:"):
                    parsed = _parse_base64(img_b64)
                    parts.append({"inline_data": {"data": parsed["data"], "mime_type": parsed["mime_type"]}})
            accessory_transform_note = (
                _build_sub_accessory_prompt(sub_product_slots, sub_product_images, accessory_start)[0]
                + "\n- While transforming angle/crop only, keep every listed accessory visible on the model — do NOT remove them."
            )
            print(f"[GENERATE] → Sub accessories attached (TRANSFORM): {len(sub_product_images)} image(s)")

        prompt_text = f"""**IMAGE TRANSFORMATION TASK**:
{transform_instruction}

{transform_crop_box_lock}

{garment_length_guide}

{body_proportion_guide}

{crop_template_guide}

{background_reference_note}

{tone_transform_note}

{accessory_transform_note}

{_build_transform_face_lighting_override(shot_label)}

{_build_face_skin_lock_prompt(True)}

{wall_shadow_block}

{single_image_lock}

**STRICT CONSTRAINTS**:
1. **SOURCE TRUTH**: Use the provided Full Body image as the ONLY reference. Do NOT change the model identity, face, hair, or the garment design/color/fit. Hair identity must remain the same (length, part direction, volume, texture, and overall silhouette). Do NOT add new facial marks, under-eye blotches, cheek spots, freckles, stains, or asymmetric skin patches that are not clearly visible in the reference.
2. **BACKGROUND**: {background_constraint}
3. **{_build_transform_lighting_prompt(shot_label)}**
{_build_transform_face_lighting_override(shot_label)}
4. **QUALITY**: Maintain 8k resolution and photorealistic commercial fashion photography style.
5. **SHOES**: Keep the exact same shoes from the reference image.
6. **FABRIC REALISM**: The clothing must look like real worn fabric — show natural wrinkles at joints, gravity-driven drape, material-specific texture (knit stitch, denim whiskering, cotton creases), and subtle compression folds where the garment is pressed or pulled. Do NOT render fabric as flat or CG-smooth. {_FABRIC_REALISM_SUFFIX}
7. **BODY PROPORTIONS**: {"Maintain natural child body proportions." if model_age_group == "kids" else "Maintain commercial lookbook proportions (~8-head). Keep the natural waist near mid-frame. Do NOT shorten the torso or exaggerate leg length."}
{"8. **ADDITIONAL**: " + sanitized_custom_prompt if sanitized_custom_prompt else ""}"""

        parts.append({"text": prompt_text})

    # ──────────────────────────────────────────────────────────
    # MODE B: ORIGINAL (no reference → full generation)
    # ──────────────────────────────────────────────────────────
    else:
        parts = []

        face_parts = _get_face_parts(model_face)
        parts.extend(face_parts)
        has_face = len(face_parts) > 0

        user_tone_part, user_tone_palette_text = _parse_user_tone_reference(tone_reference_image) if tone_reference_image else (None, "")
        has_tone_ref = user_tone_part is not None
        if user_tone_part:
            parts.append(user_tone_part)
            print("[GENERATE] → User tone palette strip attached (ORIGINAL)")

        for img_b64 in product_images:
            if isinstance(img_b64, str) and img_b64.startswith("data:"):
                parsed = _parse_base64(img_b64)
                parts.append({"inline_data": {"data": parsed["data"], "mime_type": parsed["mime_type"]}})

        product_image_count = sum(
            1 for img_b64 in product_images
            if isinstance(img_b64, str) and img_b64.startswith("data:")
        )
        accessory_image_start = 1 + (1 if has_face else 0) + (1 if has_tone_ref else 0) + product_image_count
        sub_accessory_prompt, has_sub_accessories = _build_sub_accessory_prompt(
            sub_product_slots, sub_product_images, accessory_image_start
        )
        if has_sub_accessories:
            print(f"[GENERATE] → Sub accessories: {len(sub_product_images)} image(s), slots={sub_product_slots}")
        for img_b64 in sub_product_images:
            if isinstance(img_b64, str) and img_b64.startswith("data:"):
                parsed = _parse_base64(img_b64)
                parts.append({"inline_data": {"data": parsed["data"], "mime_type": parsed["mime_type"]}})

        if has_background:
            parsed_bg = _parse_base64(background_image)
            bg_size_kb = len(parsed_bg["data"]) * 3 / 4 / 1024
            print(f"[GENERATE] → Background ref ~{bg_size_kb:.0f} KB")
            parts.append({"inline_data": {"data": parsed_bg["data"], "mime_type": parsed_bg["mime_type"]}})

        outfit_details = "\n".join([
            f"Clothing Item {i+1}: Category={info.get('category','')}, Fit={info.get('fit','')}, Length={info.get('length','')}"
            + (f", Total Length={info.get('totalLength','')}cm" if info.get('totalLength') else "")
            for i, info in enumerate(product_infos)
        ])

        # Dynamic outfit exclusion: prevent the model from hallucinating garments
        # (most commonly a jacket/outer) that weren't actually provided. The words
        # "hood" and "pocket" appear in our pose/hair rules and prime the model to
        # imagine outerwear, so we explicitly negate that.
        _CATEGORY_EN = {
            "상의": "top (shirt / t-shirt / knit / sweater / blouse)",
            "하의": "bottom (pants / trousers / jeans / shorts)",
            "원피스": "dress (one-piece)",
            "스커트": "skirt",
            "아우터": "outer layer (jacket / coat / cardigan / hoodie / vest / blazer / anorak)",
        }
        provided_cats = {(info.get("category") or "").strip() for info in product_infos}
        missing_cats = [_CATEGORY_EN[k] for k in _CATEGORY_EN if k not in provided_cats and k in _CATEGORY_EN]
        if has_sub_accessories:
            outfit_exclusion_rule = (
                f"OUTFIT STRICTNESS (CRITICAL): The model MUST wear the {len(product_infos)} main garment(s) listed in OUTFIT → Details "
                f"AND the accessories specified in ACCESSORIES below. "
                f"Accessories are add-on items for Clothing Items 1–2 — they do NOT replace main garments and do NOT count as extra layers."
            )
            if missing_cats:
                forbidden_list = "; ".join(missing_cats)
                outfit_exclusion_rule += (
                    f"\n- DO NOT add forbidden main garments: {forbidden_list}."
                    f"\n- Specifically, if '아우터 (outer layer)' is forbidden, do not add a jacket/coat/cardigan/hoodie over the top."
                )
            outfit_exclusion_rule += (
                "\n- DO NOT add any accessories beyond those shown in the accessory reference images."
                "\n- The words 'hood' and 'pocket' in pose/hair rules describe hair or hand placement — NOT clothing."
            )
        elif missing_cats:
            forbidden_list = "; ".join(missing_cats)
            outfit_exclusion_rule = (
                f"OUTFIT STRICTNESS (CRITICAL — READ CAREFULLY): The model MUST wear EXACTLY the {len(product_infos)} "
                f"garment(s) listed above — no more, no less, nothing added on top.\n"
                f"- DO NOT add any of the following to the outfit, under any circumstance: {forbidden_list}.\n"
                f"- Specifically, if '아우터 (outer layer)' is in the forbidden list above, the topmost visible garment "
                f"must be the top/knit/shirt itself. NEVER add a jacket, coat, cardigan, hoodie, vest, or blazer over it.\n"
                f"- The words 'hood' and 'pocket' may appear in the pose or hair instructions below. Those words describe "
                f"HAIR placement or HAND position ONLY — they are NOT a cue to add a hooded jacket or a garment with pockets. "
                f"If the provided garments have no hood/pocket, the generated image must also have no hood/pocket.\n"
                f"- Do NOT invent additional layers, accessories (scarf, belt, tie), or undergarments visible beyond what is listed."
            )
        else:
            outfit_exclusion_rule = (
                "OUTFIT STRICTNESS: The model wears EXACTLY the garments listed above, with no extra layers or accessories."
            )

        input_image_order_guide = _build_input_image_order_guide(
            has_face, has_tone_ref, product_image_count,
            sub_product_slots, sub_product_images, has_background,
        )

        camera_angle = _build_camera_prompt(shot_label, model_gender, model_age_group, model_height_cm, model_kids_grade)
        body_proportion_guide = _build_body_proportion_prompt(
            shot_label, model_gender, model_age_group, model_face, model_height_cm, model_kids_grade
        )
        crop_template_guide = _build_crop_template_lock(shot_label, model_age_group)
        kids_height_lock = _build_kids_height_lock_prompt(model_kids_grade, model_height_cm) if model_age_group == "kids" else ""

        face_instruction = (
            "MODEL IDENTITY (CRITICAL): The model in this photo MUST be the EXACT same person "
            "as shown in the face reference image. Copy the facial features precisely — "
            "same face shape, eyes, nose, lips, eyebrows, skin tone, and hair.\n"
            + _hair_identity_lock(model_gender) + "\n"
            + _build_face_skin_lock_prompt(True)
        ) if has_face else "MODEL: The model should have an attractive, professional look."
        # Tone reference guard — only emitted when the mosaic image is actually
        # in the input list. Wording is deliberately blunt because the SDK
        # otherwise tends to read additional images as faces or backgrounds.
        tone_instruction = _build_tone_reference_instruction(
            mode="original", has_face=has_face, palette_text=user_tone_palette_text
        ) if has_tone_ref else ""
        background_setting = (
            "SETTING (CRITICAL): Use the LAST provided image as the target background plate. Match that background's perspective, floor contact, depth, and global tone while preserving the model and outfit exactly. Background replacement must look naturally photographed, not composited."
            if has_background
            else "SETTING (CRITICAL): Soft light-gray studio background with a subtle warm gray tone — NOT pure white. A gentle, natural tonal transition between the wall and floor is acceptable to create depth and realism, similar to a real photography studio. No heavy gradients, no textures, no props."
        )
        background_reminder = (
            "- The LAST provided image is the target background; replace environment only and keep subject identity/garment geometry unchanged."
            if has_background else
            "- The background must be a soft light-gray studio tone, NOT pure white."
        )

        prompt_text = f"""Act as a professional fashion photographer.
Task: Create a high-quality lifestyle/studio fashion photography image.

{face_instruction}
{tone_instruction}

{input_image_order_guide}

FRAMING AND COMPOSITION (MANDATORY): {camera_angle}

OUTFIT: The model is wearing a coordinated outfit consisting of the provided product images.
Details: {outfit_details}

{outfit_exclusion_rule}

{sub_accessory_prompt}

{garment_length_guide}

{body_proportion_guide}

{kids_height_lock}

{crop_template_guide}

FABRIC REALISM (CRITICAL): The clothing must look like REAL worn fabric, NOT flat CG or ironed-smooth textures.
- Add natural wrinkles and creases at joints (elbows, knees, waist, crotch) from body movement.
- Show gravity-driven drape and fabric weight — heavier fabrics hang with deeper folds, lighter fabrics flow softly.
- Render material-specific texture: knit should show visible yarn/stitch texture, denim should show subtle whiskering/fading at stress points, cotton should have soft organic creases, wool should have a fuzzy nap, silk/satin should show light-catching sheen with flowing drape.
- Include subtle compression wrinkles where the garment is pulled or pressed (e.g. waistband, pocket areas, where hands rest).
- The fabric should interact naturally with the body — not floating or stiff like a 3D render.
- {_FABRIC_REALISM_SUFFIX}

{_build_default_shoes_prompt(model_age_group)}

{background_setting}

{_build_even_studio_lighting_prompt("FULL-BODY")}

{_build_even_face_lighting_override("FULL-BODY")}

{_build_face_skin_lock_prompt(has_face)}

{wall_shadow_block}

{single_image_lock}

{"ADDITIONAL INSTRUCTIONS: " + sanitized_custom_prompt if sanitized_custom_prompt else ""}

IMPORTANT REMINDERS:
- Output ONE single photograph only. Never a grid, collage, or multi-panel layout.
- Strictly adhere to the FRAMING AND COMPOSITION instruction.
- OUTFIT: wear the main garments listed in OUTFIT → Details{" AND the accessories in ACCESSORIES — accessories MUST be visible" if has_sub_accessories else ""}. Do NOT add extra main garments (jacket/coat/cardigan/hood) unless listed in OUTFIT → Details.{" Do NOT add unlisted accessories." if not has_sub_accessories else " Do NOT add accessories beyond the reference images."} The words "hood" or "pocket" in pose/hair rules refer to hair or hand placement — NOT to clothing.
{background_reminder}
{"- The tone reference is for overall color grade and mood only — never copy its grid/pattern as the background." if has_tone_ref else ""}
{"- The model's face MUST match the reference face image exactly, with the same clean even skin tone." if has_face else ""}
{"- Follow the GARMENT LENGTH guide precisely." if garment_length_guide else ""}
{"- Maintain natural child body proportions." if model_age_group == "kids" and body_proportion_guide else ("- Maintain commercial lookbook body proportions with natural waist near mid-frame; do NOT use short-torso / ultra-long-leg distortion." if body_proportion_guide else "")}"""

        parts.append({"text": prompt_text})
        print(f"[GENERATE] → ORIGINAL MODE | products: {len(product_images)}, sub_accessories: {len(sub_product_images)}, face: {has_face}, tone_ref: {has_tone_ref}")

    # ──────────────────────────────────────────────────────────
    # Call OpenAI (gpt-image-2)
    # ──────────────────────────────────────────────────────────
    try:
        print(f"[GENERATE] Calling OpenAI Images API ({OPENAI_IMAGE_MODEL})...")
        # `_openai_image_generate` is sync (OpenAI SDK calls are blocking).
        # Inside an async endpoint, calling it directly freezes the event loop
        # and serializes all concurrent requests. Offloading to the default
        # threadpool lets FastAPI actually serve requests in parallel.
        image_b64 = await asyncio.to_thread(_openai_image_generate, client, parts, image_size)
        print(f"[GENERATE] OpenAI API responded | image payload ~{len(image_b64)} chars")

        # ── Push to HF Dataset IN BACKGROUND (non-blocking) ──
        try:
            now = datetime.datetime.now()
            ts = now.strftime("%Y%m%d_%H%M%S_%f")[:-3]
            elapsed_ms = int((now - request_started_at).total_seconds() * 1000)
            raw_bytes = base64.b64decode(image_b64.split(";base64,", 1)[1]) if ";base64," in image_b64 else b""
            face_b64_hub = None
            face_filename_hub = None
            if isinstance(model_face, str):
                if model_face.startswith("preset_") and model_face in _FACE_PRESET_CACHE:
                    face_b64_hub = _FACE_PRESET_CACHE[model_face]["data"]
                    face_filename_hub = f"{model_face}.png"
                elif model_face.startswith("data:") and ";base64," in model_face:
                    face_b64_hub = model_face.split(";base64,", 1)[1]
                    face_filename_hub = model_face_filename
            # ⚡ Background push — response returns immediately
            _push_to_hub_background(
                record={
                    "timestamp": now.strftime("%Y-%m-%d %H:%M:%S"), "ts": ts,
                    "app": "ai-model-lookbook", "shot_label": shot_label,
                    "user_username": current_user["username"],
                    "user_name": current_user["name"],
                    "user_email": current_user["email"],
                    "user_logged_in": current_user["is_logged_in"],
                    "product_infos": product_infos, "custom_prompt": custom_prompt or "(none)",
                    # 전체 엔지니어링 프롬프트도 저장(상세 통계에서 표시용). 길이 방어를 위해 8000자 컷.
                    "prompt": (prompt_text[:8000] if isinstance(prompt_text, str) else ""),
                    "model_face": model_face if isinstance(model_face, str) and model_face.startswith("preset_") else "user_upload" if model_face else "none",
                    "has_reference": has_reference,
                    "has_background": has_background,
                    "has_tone_reference": bool(tone_reference_image),
                    "mode": "TRANSFORM" if has_reference else "ORIGINAL",
                    "elapsed_ms": elapsed_ms,
                    "elapsed_sec": round(elapsed_ms / 1000, 3),
                    "product_image_filenames": product_image_filenames or [],
                    "sub_product_image_filenames": sub_product_image_filenames or [],
                    "model_face_filename": model_face_filename or face_filename_hub,
                },
                image_bytes=raw_bytes, ts=ts,
                product_images_b64=product_images, face_b64=face_b64_hub,
                product_filenames=product_image_filenames,
                face_filename=face_filename_hub,
                sub_product_images_b64=sub_product_images,
                sub_product_filenames=sub_product_image_filenames,
            )
        except Exception as hub_err:
            print(f"[HUB] Failed to start background push: {hub_err}")

        elapsed_ms = int((datetime.datetime.now() - request_started_at).total_seconds() * 1000)
        print(f"[GENERATE] ✅ Returning response for '{shot_label}' | request_ts={request_ts} | elapsed_ms={elapsed_ms}")
        return {"imageUrl": image_b64}

    except HTTPException:
        raise
    except Exception as e:
        elapsed_ms = int((datetime.datetime.now() - request_started_at).total_seconds() * 1000)
        print(f"[GENERATE] ❌ Failed after {elapsed_ms} ms")
        traceback.print_exc()
        raise HTTPException(status_code=500, detail=f"이미지 생성 중 예기치 못한 오류가 발생했습니다: {str(e)[:200]}")


# ── API: Edit existing image ─────────────────────────────────

@app.post("/api/edit")
async def api_edit(request: Request, payload: Dict = Body(...)):
    client = get_openai_client()
    current_user = get_current_user(request)
    require_login(current_user)
    current_image = payload.get("currentImage", "")
    edit_instruction = payload.get("editInstruction", "")
    sanitized_edit_instruction = _sanitize_custom_prompt_for_single_image(edit_instruction)
    single_image_lock = _build_single_image_composition_lock()
    image_size = _resolve_image_size(payload.get("imageSize"))
    reference_images = payload.get("referenceImages", [])
    if not reference_images and payload.get("referenceImage"):
        reference_images = [payload["referenceImage"]]

    # Detect if the edit instruction is a background/scene change. When it is,
    # the hardcoded "preserve background" clauses below would conflict with
    # (and override) the user's intent — so we strip them for these requests.
    # Signatures come from BG_INTEGRATION_BASE in the frontend.
    _BG_CHANGE_SIGNATURES = (
        "Re-shoot this EXACT model",
        "MUST REBUILD",
        "you MUST replace the background",
        "SCENE MOOD HINT",
        "SCENE SELECTION",
        "CREATIVE DIRECTION",
    )
    is_background_change = any(sig in edit_instruction for sig in _BG_CHANGE_SIGNATURES)
    if is_background_change:
        print(f"[EDIT] Detected background-change intent — skipping 'preserve background' clauses")

    parts = []
    if current_image and current_image.startswith("data:"):
        parsed = _parse_base64(current_image)
        parts.append({"inline_data": {"data": parsed["data"], "mime_type": parsed["mime_type"]}, "preserve_format": True})
    for ref in reference_images:
        if ref and isinstance(ref, str) and ref.startswith("data:"):
            parsed = _parse_base64(ref)
            parts.append({"inline_data": {"data": parsed["data"], "mime_type": parsed["mime_type"]}})
    if reference_images:
        ref_count = sum(1 for r in reference_images if r and isinstance(r, str) and r.startswith("data:"))
        if is_background_change:
            # During background changes, references are for style/mood hints, not for locking bg.
            ref_text = (
                f"Use the following {ref_count} image(s) as visual reference(s) for style or mood hints only."
                if ref_count > 1
                else "Use the second image as a visual reference for style or mood hints only."
            )
        else:
            ref_text = (
                f"Use the following {ref_count} image(s) as visual reference(s) for the requested changes only. "
                "Do NOT copy background, environment, or tone from the reference images — preserve the background from the first image."
            ) if ref_count > 1 else (
                "Use the second image as a visual reference for the requested changes only. "
                "Do NOT copy background or environment from the reference — preserve the background from the first image."
            )
        parts.append({"text": ref_text})

    if is_background_change:
        # Background changes: user's instruction IS the scene directive. Do NOT
        # add any "preserve background" counter-instruction here.
        parts.append({
            "text": f"{sanitized_edit_instruction}\n\n"
                    f"{single_image_lock}\n\n"
                    f"LOCAL EDIT ONLY (ABSOLUTE): Apply only the requested change. Do NOT perform a full-image retouch, beauty retouch, relighting pass, color grading pass, tone matching pass, or global contrast/saturation/white-balance change.\n\n"
                    f"SUBJECT LIGHTING LOCK (CRITICAL): Preserve the first image's lighting on the person exactly. Face tone, skin brightness, shadow direction, highlight intensity, color temperature, and garment lighting must remain unchanged. The new background may be integrated with shadows/contact only, but must NOT relight or recolor the person.\n\n"
                    f"EDIT LOCALITY LOCK (CRITICAL): All unrequested regions from the first image must remain visually unchanged. Unless the instruction explicitly asks to edit face or skin, the face, neck, hands, legs, and all exposed skin are non-editable reference areas, not areas to regenerate.\n\n"
                    f"FACE / SKIN LOCK (CRITICAL): Preserve the first image's face exactly. Skin texture, facial lighting, natural highlights, pores, and skin tone gradients must remain unchanged. Do NOT smooth, repaint, mottle, blur, recolor, brighten, darken, or patchily relight the person's skin.\n\n"
                    f"MODEL RATIO LOCK (CRITICAL): Preserve the first image's body proportions exactly. Do NOT change apparent height, head size, shoulder width, torso length, waist/hip/knee position, leg length, ankle position, or foot scale. Background or pose changes must not alter the model's body scale.\n\n"
                    f"The output must be a high-quality fashion photograph. Keep the original subject pixels visually stable while applying the requested change."
        })
    else:
        parts.append({
            "text": f"Modify this fashion photo based on the following instruction: {sanitized_edit_instruction}. "
                    f"{single_image_lock} "
                    f"LOCAL EDIT ONLY (ABSOLUTE): Apply only the requested change. Do NOT perform a full-image retouch, beauty retouch, relighting pass, color grading pass, tone matching pass, or global contrast/saturation/white-balance change. "
                    f"SUBJECT LIGHTING LOCK (CRITICAL): Preserve the first image's lighting on the person exactly. Face tone, skin brightness, shadow direction, highlight intensity, color temperature, and garment lighting must remain unchanged. "
                    f"EDIT LOCALITY LOCK (CRITICAL): All unrequested regions from the first image must remain visually unchanged. Unless the instruction explicitly asks to edit face or skin, the face, neck, hands, legs, and all exposed skin are non-editable reference areas, not areas to regenerate. "
                    f"Maintain the same model and outfit with high fidelity, and preserve composition unless the instruction explicitly requests a framing change. "
                    f"MODEL RATIO LOCK (CRITICAL): Preserve the first image's body proportions exactly. Do NOT change apparent height, head size, shoulder width, torso length, waist/hip/knee position, leg length, ankle position, or foot scale. "
                    f"FACE / SKIN LOCK (CRITICAL): Preserve the first image's face exactly. Skin texture, facial lighting, natural highlights, pores, and skin tone gradients must remain unchanged. Do NOT smooth, repaint, mottle, blur, recolor, brighten, darken, or patchily relight the person's skin. "
                    f"BACKGROUND (CRITICAL): Keep the exact same background — tone, color, and environment — as in the first image. "
                    f"Do NOT change the background unless the instruction explicitly requests a background change. "
                    f"The output must be a high-quality fashion photograph. Keep all non-requested pixels visually stable."
        })

    try:
        image_b64 = await asyncio.to_thread(_openai_image_generate, client, parts, image_size)

        # Background push for edit too
        try:
            now = datetime.datetime.now()
            ts = now.strftime("%Y%m%d_%H%M%S_%f")[:-3]
            raw_bytes = base64.b64decode(image_b64.split(";base64,", 1)[1]) if ";base64," in image_b64 else b""
            _push_to_hub_background(
                record={
                    "timestamp": now.strftime("%Y-%m-%d %H:%M:%S"), "ts": ts,
                    "app": "ai-model-lookbook", "type": "edited",
                    "user_username": current_user["username"],
                    "user_name": current_user["name"],
                    "user_email": current_user["email"],
                    "user_logged_in": current_user["is_logged_in"],
                    "edit_instruction": edit_instruction,
                    "is_background_change": is_background_change,
                },
                image_bytes=raw_bytes, ts=ts,
            )
        except Exception:
            pass

        return {"imageUrl": image_b64}

    except HTTPException:
        raise
    except Exception as e:
        traceback.print_exc()
        raise HTTPException(status_code=500, detail=f"이미지 편집 중 예기치 못한 오류가 발생했습니다: {str(e)[:200]}")


# ══════════════════════════════════════════════════════════════
# Comfy Cloud 브리지 (SPAO 워크플로우) — 제출→폴링 구조, comfy_routes.py 참조
# ══════════════════════════════════════════════════════════════
try:
    from comfy_routes import build_comfy_router
    app.include_router(build_comfy_router(get_current_user, require_login, _FACE_PRESET_CACHE, _push_to_hub_background))
    print("[COMFY] routes mounted: /api/comfy/generate, /api/comfy/jobs/{id}, /api/comfy/health")
except Exception as _comfy_err:
    traceback.print_exc()
    print(f"[COMFY] failed to mount routes: {_comfy_err}")