"""SDXL LoRA Eğitim — diffusers + peft tabanlı, subprocess olmadan in-process.

Kohya_ss veya ai-toolkit'e gerek yok; diffusers'ın PEFT entegrasyonu yeterli.
Kullanım:
    from lora_egit import lora_egit
    lora_egit(
        veri_klasoru="...",
        cikti_yolu="loralar/burcu_v1.safetensors",
        base_model="SG161222/RealVisXL_V4.0",
        tetikleyici="zwx_burcu",
        adim=1500,
        ilerleme_cb=lambda step, toplam, faz: ...
    )
"""
from __future__ import annotations

import os
import gc
import json
import math
import time
import random
from pathlib import Path
from typing import Optional, Callable, List, Dict

import torch
import torch.nn.functional as F
from PIL import Image
import numpy as np


# Yardımcı: yüz seti dosyalarını listele
def _veri_dosyalari(klasor: str) -> List[Path]:
    p = Path(klasor)
    if not p.exists():
        return []
    izinli = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
    return sorted([f for f in p.iterdir()
                   if f.is_file() and f.suffix.lower() in izinli])


def _resmi_kareye_al(img: Image.Image, hedef: int = 1024) -> Image.Image:
    """Görseli kare kırp + hedef boyuta resize."""
    w, h = img.size
    kucuk = min(w, h)
    sol = (w - kucuk) // 2
    ust = (h - kucuk) // 2
    img = img.crop((sol, ust, sol + kucuk, ust + kucuk))
    return img.resize((hedef, hedef), Image.LANCZOS)


class GorselDataset(torch.utils.data.Dataset):
    """Karakter yüz setinden eğitim örneği üretir.
    promptlar: per-image (smart captioning) veya tek bir prompt.
    """

    def __init__(self, dosyalar: List[Path], boyut: int,
                 prompt: str, promptlar: Optional[List[str]] = None):
        self.dosyalar = dosyalar
        self.boyut = boyut
        self.prompt = prompt
        self.promptlar = promptlar  # opsiyonel, len(dosyalar) ile eşleşmeli

    def __len__(self):
        return len(self.dosyalar)

    def __getitem__(self, idx):
        img = Image.open(self.dosyalar[idx]).convert("RGB")
        img = _resmi_kareye_al(img, self.boyut)
        arr = np.array(img).astype(np.float32) / 127.5 - 1.0  # [-1, 1]
        arr = arr.transpose(2, 0, 1)  # CHW
        prompt = (self.promptlar[idx]
                  if self.promptlar and idx < len(self.promptlar)
                  else self.prompt)
        return {
            "pixel_values": torch.from_numpy(arr),
            "prompt": prompt,
        }


# WD14 — yüz/saç/cilt tag'leri filtrelenir, sadece kıyafet/poz/sahne kalır
_WD14_YASAK = {
    "blue_eyes", "green_eyes", "brown_eyes", "red_eyes", "yellow_eyes",
    "black_hair", "blonde_hair", "brown_hair", "white_hair", "red_hair",
    "long_hair", "short_hair", "medium_hair", "ponytail", "bangs",
    "face", "nose", "lips", "mouth", "eyelashes", "eyebrows",
    "smile", "skin", "freckles", "makeup", "lipstick",
    "1girl", "1boy", "solo", "looking_at_viewer",
}


def _wd14_caption_olustur(dosyalar: List[Path],
                           tetikleyici: str, cins: str,
                           threshold: float = 0.35) -> List[str]:
    """Her foto için WD14 ile tag çıkar, yüz/saç/göz tag'lerini filtrele.
    Geri dönüş: prompt listesi (len = len(dosyalar))."""
    try:
        import onnxruntime as ort
        from huggingface_hub import hf_hub_download
        import csv
    except ImportError as e:
        print(f"[LoRA] WD14 bağımlılıkları yok ({e}) → tek prompt kullanılır")
        return [f"a photo of {tetikleyici} {cins}"] * len(dosyalar)

    try:
        repo = "SmilingWolf/wd-v1-4-moat-tagger-v2"
        model_path = hf_hub_download(repo, "model.onnx")
        labels_path = hf_hub_download(repo, "selected_tags.csv")
        # Etiketleri oku
        labels = []
        with open(labels_path, "r", encoding="utf-8") as f:
            reader = csv.DictReader(f)
            for row in reader:
                labels.append(row["name"])
        # CPU provider — CUDA cuDNN sorunlarından kaçın, captioning zaten hızlı
        sess = ort.InferenceSession(
            model_path, providers=["CPUExecutionProvider"])
        input_name = sess.get_inputs()[0].name
        target_size = 448
    except Exception as e:
        print(f"[LoRA] WD14 yüklenemedi ({e}) → tek prompt")
        return [f"a photo of {tetikleyici} {cins}"] * len(dosyalar)

    promptlar = []
    for f in dosyalar:
        try:
            img = Image.open(f).convert("RGB")
            # Kareye doldur (WD14 padding bekler)
            w, h = img.size
            uzun = max(w, h)
            kare = Image.new("RGB", (uzun, uzun), (255, 255, 255))
            kare.paste(img, ((uzun - w) // 2, (uzun - h) // 2))
            kare = kare.resize((target_size, target_size), Image.BICUBIC)
            arr = np.array(kare).astype(np.float32)
            arr = arr[:, :, ::-1]  # RGB → BGR
            arr = np.expand_dims(arr, 0)
            probs = sess.run(None, {input_name: arr})[0][0]
            tags = [labels[i] for i, p in enumerate(probs)
                    if p > threshold and i < len(labels)]
            # Yüz/saç/göz tag'lerini filtrele
            temiz = [t for t in tags
                     if not any(y == t.replace(" ", "_")
                                for y in _WD14_YASAK)]
            # Trigger + cins ile prompt oluştur
            base = f"a photo of {tetikleyici} {cins}"
            if temiz:
                prompt = f"{base}, " + ", ".join(temiz[:30])
            else:
                prompt = base
            promptlar.append(prompt)
        except Exception as e:
            print(f"[LoRA] {f.name} caption hatası: {e}")
            promptlar.append(f"a photo of {tetikleyici} {cins}")

    print(f"[LoRA] {len(promptlar)} foto için WD14 caption oluşturuldu")
    if promptlar:
        print(f"[LoRA] Örnek: {promptlar[0][:120]}")
    # Onnx session belleği serbest
    del sess
    gc.collect()
    return promptlar


def lora_egit(
    veri_klasoru: str,
    cikti_yolu: str,
    base_model: str,
    tetikleyici: str = "zwx_person",
    cins: str = "person",  # 'woman', 'man', 'person'
    adim: int = 1500,
    rank: int = 16,
    lr: float = 1e-4,
    boyut: int = 1024,
    karakter_id: Optional[str] = None,
    lora_isim: Optional[str] = None,
    mevcut_lora_yolu: Optional[str] = None,
    use_dora: bool = False,
    min_snr_gamma: float = 0.0,  # 0 = kapalı, 5.0 = standart
    smart_caption: bool = False,  # WD14 ile per-image caption
    auto_test: bool = False,  # eğitim sonrası kalite testi
    ilerleme_cb: Optional[Callable[[int, int, str], None]] = None,
    iptal_kontrol: Optional[Callable[[], bool]] = None,
) -> dict:
    """SDXL DreamBooth LoRA eğitimi — peft ile rank-decomposed update."""
    t0 = time.time()
    dosyalar = _veri_dosyalari(veri_klasoru)
    if not dosyalar:
        return {"basarili": False, "hata": "Veri klasöründe görsel yok"}
    if len(dosyalar) < 5:
        return {"basarili": False,
                "hata": "En az 5 yüz fotoğrafı gerekli (önerilen 10-20)"}

    if ilerleme_cb:
        ilerleme_cb(0, adim, "Boru hazırlanıyor")

    from diffusers import (
        StableDiffusionXLPipeline,
        DDPMScheduler,
        AutoencoderKL,
    )
    from peft import LoraConfig, get_peft_model_state_dict
    from peft.utils import set_peft_model_state_dict
    from diffusers.utils import convert_state_dict_to_diffusers
    from safetensors.torch import save_file

    cuda = torch.cuda.is_available()
    cihaz = "cuda" if cuda else "cpu"
    # Base modeller fp16 (VRAM); LoRA params fp32 (stabilite) — autocast ile
    dtype = torch.float16 if cuda else torch.float32

    # 1) Base model yükle
    pipe = StableDiffusionXLPipeline.from_pretrained(
        base_model,
        torch_dtype=dtype,
        use_safetensors=True,
        variant=("fp16" if dtype == torch.float16 else None),
    )
    # Eğitimde fp32 weights tutmak için VAE fp32, UNet fp16 ok
    try:
        fp16_vae = AutoencoderKL.from_pretrained(
            "madebyollin/sdxl-vae-fp16-fix", torch_dtype=dtype,
        )
        pipe.vae = fp16_vae
    except Exception:
        pass
    pipe.scheduler = DDPMScheduler.from_config(pipe.scheduler.config)

    unet = pipe.unet
    vae = pipe.vae
    text_encoder_1 = pipe.text_encoder
    text_encoder_2 = pipe.text_encoder_2
    tokenizer_1 = pipe.tokenizer
    tokenizer_2 = pipe.tokenizer_2
    noise_scheduler = pipe.scheduler

    # GPU'ya taşı (eğitimde model_cpu_offload kullanma — sürekli swap olur)
    if cuda:
        unet.to(cihaz, dtype=dtype)
        vae.to(cihaz, dtype=dtype)
        text_encoder_1.to(cihaz, dtype=dtype)
        text_encoder_2.to(cihaz, dtype=dtype)

    # 2) UNet'i dondur, LoRA ekle
    unet.requires_grad_(False)
    vae.requires_grad_(False)
    text_encoder_1.requires_grad_(False)
    text_encoder_2.requires_grad_(False)

    # DoRA: Weight-Decomposed LoRA (peft >= 0.9). Subject fidelity %10-20 daha iyi
    # ama %30 yavaş ve ~%5-10 fazla VRAM. PEFT eski sürümde use_dora yoksa fallback.
    lora_kwargs = {
        "r": rank,
        "lora_alpha": rank,
        "init_lora_weights": "gaussian",
        "target_modules": ["to_k", "to_q", "to_v", "to_out.0"],
    }
    if use_dora:
        try:
            # use_dora alanı LoraConfig'de var mı kontrol
            from peft import LoraConfig as _LC
            import inspect as _inspect
            if "use_dora" in _inspect.signature(_LC.__init__).parameters:
                lora_kwargs["use_dora"] = True
                print("[LoRA] DoRA aktif (subject fidelity ↑)")
            else:
                print("[LoRA] DoRA istendi ama PEFT versiyonu desteklemiyor "
                      "(>=0.9 gerekli) → klasik LoRA")
        except Exception:
            pass
    lora_config = LoraConfig(**lora_kwargs)
    unet.add_adapter(lora_config)

    # LoRA params'ı fp32'ye yükselt — fp16 underflow → NaN'ı engeller
    # (Standart SDXL LoRA training pattern: mixed precision)
    if cuda:
        for p in unet.parameters():
            if p.requires_grad:
                p.data = p.data.to(torch.float32)
        print("[LoRA] LoRA params fp32'ye yükseltildi (mixed precision)")

    # ===== Mevcut LoRA üzerine devam et =====
    if mevcut_lora_yolu and Path(mevcut_lora_yolu).exists():
        try:
            from safetensors import safe_open
            print(f"[LoRA] Mevcut weights yükleniyor: {mevcut_lora_yolu}")
            # mmap'tan kopyala (Windows file lock'u önlemek için clone)
            yuklenen = {}
            with safe_open(mevcut_lora_yolu, framework="pt",
                           device="cpu") as f:
                for k in f.keys():
                    yuklenen[k] = f.get_tensor(k).clone()
            # Diffusers formatından PEFT formatına dönüştür
            peft_state = None
            try:
                from diffusers.utils import convert_unet_state_dict_to_peft
                peft_state = convert_unet_state_dict_to_peft(yuklenen)
            except (ImportError, AttributeError):
                # Eski diffusers — manuel dönüşüm
                # Diffusers LoRA: "unet.X.processor.to_q_lora.down.weight"
                # Modern: "X.to_q.lora_A.weight" → "base_model.model.X.to_q.lora_A.default.weight"
                peft_state = {}
                for k, v in yuklenen.items():
                    nk = k
                    # "unet." prefix kaldır
                    if nk.startswith("unet."):
                        nk = nk[len("unet."):]
                    # Eski diffusers pattern: ".processor.to_q_lora.down.weight"
                    # → ".to_q.lora_A.weight"
                    nk = nk.replace(".processor.", ".")
                    nk = nk.replace("_lora.down.weight", ".lora_A.weight")
                    nk = nk.replace("_lora.up.weight", ".lora_B.weight")
                    # PEFT default adapter
                    if ".lora_A.weight" in nk:
                        nk = nk.replace(".lora_A.weight",
                                        ".lora_A.default.weight")
                    elif ".lora_B.weight" in nk:
                        nk = nk.replace(".lora_B.weight",
                                        ".lora_B.default.weight")
                    if not nk.startswith("base_model.model."):
                        nk = "base_model.model." + nk
                    peft_state[nk] = v
            uyumsuz = set_peft_model_state_dict(unet, peft_state,
                                                adapter_name="default")
            try:
                if hasattr(uyumsuz, "missing_keys"):
                    print(f"[LoRA] Eksik: {len(uyumsuz.missing_keys)} "
                          f"Beklenmeyen: {len(uyumsuz.unexpected_keys)}")
            except Exception:
                pass
            print("[LoRA] Mevcut weights başarıyla yüklendi → eğitim devam ediyor")
        except Exception as e:
            print(f"[LoRA] Mevcut weights yüklenemedi ({e}) → sıfırdan eğitim")

    # Eğitilebilir parametreleri topla
    egitilebilir = [p for p in unet.parameters() if p.requires_grad]
    print(f"[LoRA] Eğitilebilir parametre sayısı: "
          f"{sum(p.numel() for p in egitilebilir):,}")

    # Devam ediyorsak LR'yi yarıya indir (fine-tuning, weights zaten öğrenilmiş)
    etkin_lr = lr
    if mevcut_lora_yolu:
        etkin_lr = lr * 0.5
        print(f"[LoRA] Devam modu: LR {lr} → {etkin_lr} (yarıya indirildi)")

    # 3) Optimizer (eps fp16 için biraz büyütüldü, NaN riski azaltır)
    optimizer = torch.optim.AdamW(
        egitilebilir, lr=etkin_lr, betas=(0.9, 0.999),
        weight_decay=1e-2, eps=1e-6,
    )

    # 4) Veri seti
    instance_prompt = f"a photo of {tetikleyici} {cins}"
    class_prompt = f"a photo of a {cins}"  # regularization için (kullanmıyoruz)

    # Smart captioning — WD14 ile per-image prompt
    promptlar = None
    if smart_caption:
        if ilerleme_cb:
            ilerleme_cb(0, adim, "WD14 ile fotolar etiketleniyor")
        promptlar = _wd14_caption_olustur(dosyalar, tetikleyici, cins)
    ds = GorselDataset(dosyalar, boyut, instance_prompt, promptlar)

    # 5) Tokenize prompts (önceden, statik)
    def tokenize(prompt):
        ids1 = tokenizer_1(
            prompt, padding="max_length",
            max_length=tokenizer_1.model_max_length,
            truncation=True, return_tensors="pt",
        ).input_ids.to(cihaz)
        ids2 = tokenizer_2(
            prompt, padding="max_length",
            max_length=tokenizer_2.model_max_length,
            truncation=True, return_tensors="pt",
        ).input_ids.to(cihaz)
        return ids1, ids2

    @torch.no_grad()
    def encode_prompt(prompt):
        ids1, ids2 = tokenize(prompt)
        out1 = text_encoder_1(ids1, output_hidden_states=True)
        prompt_emb_1 = out1.hidden_states[-2]
        out2 = text_encoder_2(ids2, output_hidden_states=True)
        prompt_emb_2 = out2.hidden_states[-2]
        pooled = out2[0]  # pooled_output
        prompt_embeds = torch.cat([prompt_emb_1, prompt_emb_2], dim=-1)
        return prompt_embeds, pooled

    # Sabit prompt embeddings (smart_caption=False için)
    # Smart caption modunda her foto'nun kendi prompt'u var → cache'le
    prompt_embed_cache: Dict[str, tuple] = {}

    def encode_or_cache(prompt: str):
        if prompt not in prompt_embed_cache:
            pe, pl = encode_prompt(prompt)
            prompt_embed_cache[prompt] = (pe, pl)
        return prompt_embed_cache[prompt]

    if smart_caption and promptlar:
        # Cache büyürse RAM riskli — büyük dataset uyarısı
        if ilerleme_cb:
            ilerleme_cb(0, adim, "Prompt embeddings hesaplanıyor")
        benzersiz = list(set(promptlar))
        # Her prompt embed ~250 KB → 200+ benzersiz prompt = 50+ MB
        if len(benzersiz) > 200:
            print(f"[LoRA] UYARI: {len(benzersiz)} benzersiz prompt cache "
                  f"~{len(benzersiz) * 0.25:.0f} MB RAM kullanır")
        for p in benzersiz:
            encode_or_cache(p)
        print(f"[LoRA] {len(benzersiz)} benzersiz prompt encode edildi")
        prompt_embeds, pooled_embeds = encode_or_cache(promptlar[0])
    else:
        prompt_embeds, pooled_embeds = encode_prompt(instance_prompt)

    # SDXL conditioning vectors (time_ids: target_size, crop, original)
    add_time_ids = torch.tensor(
        [[boyut, boyut, 0, 0, boyut, boyut]], dtype=dtype, device=cihaz,
    )

    if ilerleme_cb:
        ilerleme_cb(0, adim, "Eğitim başlıyor")

    # 6) Eğitim döngüsü
    unet.train()
    son_loss = 0.0
    veri_idx = 0
    nan_seri = 0  # ardışık NaN sayacı
    for step in range(adim):
        if iptal_kontrol and iptal_kontrol():
            print("[LoRA] Eğitim iptal edildi")
            return {"basarili": False, "hata": "İptal edildi"}

        # Veri al (round-robin)
        veri_i = veri_idx % len(ds)
        sample = ds[veri_i]
        veri_idx += 1
        pixels = sample["pixel_values"].unsqueeze(0).to(cihaz, dtype=dtype)

        # Smart captioning: bu sample'ın prompt embeddings'ini kullan
        if smart_caption and promptlar:
            prompt_embeds, pooled_embeds = encode_or_cache(sample["prompt"])

        # 1) Latent encode
        with torch.no_grad():
            latents = vae.encode(pixels).latent_dist.sample()
            latents = latents * vae.config.scaling_factor

        # 2) Noise + timestep
        noise = torch.randn_like(latents)
        bsz = latents.shape[0]
        timesteps = torch.randint(
            0, noise_scheduler.config.num_train_timesteps,
            (bsz,), device=cihaz,
        ).long()
        noisy_latents = noise_scheduler.add_noise(latents, noise, timesteps)

        # 3) UNet predict — autocast ile mixed precision
        added_cond_kwargs = {
            "text_embeds": pooled_embeds,
            "time_ids": add_time_ids,
        }
        if cuda:
            with torch.autocast("cuda", dtype=torch.float16):
                model_pred = unet(
                    noisy_latents,
                    timesteps,
                    prompt_embeds,
                    added_cond_kwargs=added_cond_kwargs,
                    return_dict=False,
                )[0]
        else:
            model_pred = unet(
                noisy_latents,
                timesteps,
                prompt_embeds,
                added_cond_kwargs=added_cond_kwargs,
                return_dict=False,
            )[0]

        # 4) Hedef
        if noise_scheduler.config.prediction_type == "epsilon":
            target = noise
        elif noise_scheduler.config.prediction_type == "v_prediction":
            target = noise_scheduler.get_velocity(latents, noise, timesteps)
        else:
            target = noise

        # Min-SNR-gamma loss: gamma > 0 ise SNR-ağırlıklı MSE
        # Yüksek timestep'lerde loss küçülür → daha hızlı/stabil yakınsama
        if min_snr_gamma > 0:
            # alphas_cumprod fp32 olmalı (fp16 underflow → 0 → div hatası)
            alphas = noise_scheduler.alphas_cumprod.to(
                device=cihaz, dtype=torch.float32)
            alpha_t = alphas[timesteps]
            snr = alpha_t / (1.0 - alpha_t).clamp(min=1e-8)
            # Pay = clamp(SNR, gamma); Payda = SNR (epsilon prediction için)
            # v_prediction için (snr+1) ama SDXL default epsilon
            agirlik = torch.minimum(
                snr, torch.full_like(snr, min_snr_gamma)) / snr.clamp(min=1e-8)
            loss_t = F.mse_loss(model_pred.float(), target.float(),
                                reduction="none")
            # Per-sample ortalama (CHW → tek skaler her örnek için)
            loss_t = loss_t.mean(dim=list(range(1, loss_t.ndim)))
            loss = (loss_t * agirlik).mean()
        else:
            loss = F.mse_loss(model_pred.float(), target.float(),
                              reduction="mean")

        # NaN/Inf guard — adımı atla, weights'i bozma
        if torch.isnan(loss) or torch.isinf(loss):
            optimizer.zero_grad(set_to_none=True)
            nan_seri += 1
            if step % 25 == 0:
                print(f"[LoRA] step {step + 1}/{adim} loss=NaN/Inf "
                      f"(seri: {nan_seri})")
            if ilerleme_cb and step % 5 == 0:
                ilerleme_cb(step + 1, adim,
                            f"Eğitim — NaN atlandı ({nan_seri})")
            # 30+ ardışık NaN → eğitim bozulmuş, iptal
            if nan_seri >= 30:
                return {
                    "basarili": False,
                    "hata": f"{nan_seri} ardışık NaN. Weights bozuldu. "
                            f"LR çok yüksek veya veri seti hatalı. "
                            f"Sıfırdan daha düşük LR (5e-5) ile dene.",
                }
            continue
        # nan_seri başarılı optimizer.step() sonunda sıfırlanır

        # 5) Backward + optim
        loss.backward()
        torch.nn.utils.clip_grad_norm_(egitilebilir, 1.0)

        # Gradient NaN check — varsa adımı atla
        gradient_nan = False
        for p in egitilebilir:
            if p.grad is not None and (torch.isnan(p.grad).any()
                                       or torch.isinf(p.grad).any()):
                gradient_nan = True
                break
        if gradient_nan:
            optimizer.zero_grad(set_to_none=True)
            nan_seri += 1
            print(f"[LoRA] step {step + 1}/{adim} gradient NaN → atlandı "
                  f"(seri: {nan_seri})")
            if nan_seri >= 30:
                return {
                    "basarili": False,
                    "hata": (f"{nan_seri} ardışık gradient NaN. LR çok "
                             f"yüksek. Daha düşük LR (5e-5) ile dene."),
                }
            continue

        optimizer.step()
        optimizer.zero_grad(set_to_none=True)
        # Başarılı adım → ardışık NaN sayacını gradient adımı sonrasında sıfırla
        nan_seri = 0

        son_loss = float(loss.item())
        if step % 25 == 0 or step == adim - 1:
            print(f"[LoRA] step {step + 1}/{adim} loss={son_loss:.4f}")
        if ilerleme_cb and (step % 5 == 0 or step == adim - 1):
            ilerleme_cb(step + 1, adim, f"Eğitim — loss {son_loss:.3f}")

    # 7) LoRA weights çıkar ve kaydet
    if ilerleme_cb:
        ilerleme_cb(adim, adim, "Kaydediliyor")
    Path(cikti_yolu).parent.mkdir(parents=True, exist_ok=True)
    unet_lora_state = convert_state_dict_to_diffusers(
        get_peft_model_state_dict(unet)
    )

    # NaN guard — bozuk weights'i kaydetme
    nan_var = False
    for k, v in unet_lora_state.items():
        if torch.isnan(v).any().item() or torch.isinf(v).any().item():
            nan_var = True
            print(f"[LoRA] HATA: {k} NaN/Inf içeriyor")
            break
    if nan_var:
        return {
            "basarili": False,
            "hata": ("Eğitim NaN'a düştü, weights bozuk. LR çok yüksek "
                     "olabilir (devam et modunda 5e-5 dene) veya veri "
                     "seti hatalı."),
        }

    # CPU'ya kopyala — Ana mmap'ten ayır (Windows file lock fix)
    unet_lora_state_cpu = {
        k: v.detach().cpu().contiguous().clone()
        for k, v in unet_lora_state.items()
    }

    # Atomic save: temp dosyaya yaz, sonra üzerine taşı (mmap fix)
    import os as _os
    cikti_path = Path(cikti_yolu)
    temp_yolu = cikti_path.with_suffix(".tmp.safetensors")
    save_file(unet_lora_state_cpu, str(temp_yolu))
    # Hedef varsa silmeyi dene (Windows: replace zaten atomic)
    try:
        _os.replace(str(temp_yolu), str(cikti_path))
    except Exception as e:
        # mmap kilitli ise: gc + tekrar dene
        del unet_lora_state, unet_lora_state_cpu
        gc.collect()
        if cuda:
            torch.cuda.empty_cache()
        time.sleep(0.5)
        try:
            _os.replace(str(temp_yolu), str(cikti_path))
        except Exception:
            print(f"[LoRA] HATA: Dosya yazılamadı ({e}). Temp kaldı: {temp_yolu}")
            return {"basarili": False,
                    "hata": f"Dosya kilidi: {e}. Üretim sunucusunu "
                            f"yeniden başlat ve tekrar dene."}

    # Yan dosya — metadata (NaN/Inf'ten korun)
    son_loss_safe = (son_loss
                     if (not math.isnan(son_loss)
                         and not math.isinf(son_loss))
                     else 0.0)
    # Toplam adım: devam ediyorsak eski meta ile birleştir
    toplam_adim = adim
    eski_meta = {}
    eski_meta_yolu = Path(cikti_yolu).with_suffix(".json")
    if mevcut_lora_yolu and eski_meta_yolu.exists():
        try:
            eski_meta = json.loads(
                eski_meta_yolu.read_text(encoding="utf-8"))
            toplam_adim = int(eski_meta.get("adim", 0)) + adim
        except Exception:
            pass

    meta = {
        # Devam modunda eski meta'yı temel al (user-edited isim/tetikleyici
        # gibi alanları korur), yeni alanlar üstüne yazar
        **eski_meta,
        "tetikleyici": tetikleyici,
        "cins": cins,
        "base_model": base_model,
        "adim": toplam_adim,
        "son_egitim_adim": adim,
        "rank": rank,
        "lr": lr,
        "boyut": boyut,
        "veri_klasoru": str(veri_klasoru),
        "veri_sayisi": len(dosyalar),
        "son_loss": son_loss_safe,
        "sure_sn": time.time() - t0,
        "tarih": time.strftime("%Y-%m-%d %H:%M:%S"),
        "karakter_id": karakter_id,
        "isim": lora_isim,
        "devam_edildi": bool(mevcut_lora_yolu),
        "use_dora": use_dora,
        "min_snr_gamma": min_snr_gamma,
        "smart_caption": smart_caption,
    }

    # ===== Auto-test (eğitim sonrası kalite testi) =====
    if auto_test:
        try:
            if ilerleme_cb:
                ilerleme_cb(adim, adim, "Kalite testi yapılıyor")
            # Eğitilen unet'i test için kullan (yükleme yok, in-memory)
            # Belleği boşaltmadan önce test et
            try:
                del unet_lora_state_cpu  # bunu boşalt
            except NameError:
                pass
            # UNet inference moduna al + LoRA params'ı tekrar fp16'ya
            # düşür (pipe fp16 ile çakışmasın)
            try:
                unet.eval()
                if cuda:
                    for p in unet.parameters():
                        if p.requires_grad and p.dtype == torch.float32:
                            p.data = p.data.to(torch.float16)
            except Exception:
                pass
            gc.collect()
            if cuda:
                torch.cuda.empty_cache()
            test_sonucu = _kalite_testi_yap(
                pipe=pipe,
                cikti_yolu=str(cikti_path),
                tetikleyici=tetikleyici,
                cins=cins,
                referans_dosyalar=dosyalar,
                cihaz=cihaz,
            )
            meta["kalite_testi"] = test_sonucu
            print(f"[LoRA] Kalite testi: skor={test_sonucu.get('kimlik_skoru', 0):.3f} "
                  f"karar={test_sonucu.get('karar', '?')}")
        except Exception as e:
            print(f"[LoRA] Auto-test hatası: {e}")
            import traceback as _tb
            _tb.print_exc()
            meta["kalite_testi"] = {"hata": str(e)}

    Path(cikti_yolu).with_suffix(".json").write_text(
        json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8",
    )

    # Belleği temizle
    try:
        del unet, vae, text_encoder_1, text_encoder_2, pipe
    except Exception:
        pass
    gc.collect()
    if cuda:
        torch.cuda.empty_cache()

    return {
        "basarili": True,
        "yol": cikti_yolu,
        "meta": meta,
    }


# Auto-test için sabit prompt seti (3 boyut: ifade/kıyafet/ortam)
_TEST_PROMPTLARI = [
    "{trg} portrait, neutral expression, studio lighting",
    "{trg} smiling, outdoor daylight, natural background",
    "{trg} wearing red dress, full body, urban street",
    "{trg} at the beach, sunset, candid photo",
    "{trg} side profile, black and white photography",
    "{trg} laughing, close-up, bokeh background",
    "{trg} in business suit, office environment",
    "{trg} wearing winter coat, snowy mountain",
]


def _kalite_testi_yap(pipe, cikti_yolu: str, tetikleyici: str,
                      cins: str, referans_dosyalar: List[Path],
                      cihaz: str) -> dict:
    """Eğitim sonrası: 8 prompt ile gen yap, ArcFace ile referans setteki
    yüzlerle similarity hesapla, overfit/quality skoru çıkar.

    Süre: ~3 dk (RTX 4070), Sonuç: skor + karar + uyarılar.
    """
    import numpy as np
    sonuc = {
        "kimlik_skoru": 0.0,
        "kimlik_min": 0.0,
        "kimlik_std": 0.0,
        "test_sayisi": 0,
        "karar": "BILINMIYOR",
        "uyarilar": [],
    }

    try:
        from insightface.app import FaceAnalysis
    except ImportError:
        sonuc["uyarilar"].append("InsightFace yok — test atlandı")
        return sonuc

    # Referans embedding'leri çıkar
    try:
        face_app = FaceAnalysis(
            name="buffalo_l",
            providers=["CPUExecutionProvider"],
        )
        # ctx_id=-1 → CPU (8 prompt × yüz tespiti CPU'da ~30-60sn)
        face_app.prepare(ctx_id=-1, det_size=(640, 640))
    except Exception as e:
        sonuc["uyarilar"].append(f"FaceAnalysis init hatası: {e}")
        return sonuc

    ref_embeds = []
    for f in referans_dosyalar[:20]:  # max 20 referans
        try:
            img_bgr = np.array(Image.open(f).convert("RGB"))[:, :, ::-1]
            yuzler = face_app.get(img_bgr)
            if yuzler:
                ref_embeds.append(yuzler[0].normed_embedding)
        except Exception:
            continue

    if not ref_embeds:
        sonuc["uyarilar"].append("Referans yüz tespit edilemedi")
        return sonuc
    ref_arr = np.stack(ref_embeds, axis=0)

    # Pipe'a eğitilen LoRA'yı yükle (eğitim sonrası unet zaten LoRA'lı,
    # ama save sonrası fresh pipe ile test daha temiz)
    try:
        # Aktif adapter'ı kullan (eğitim biten unet hâlâ pipe'da)
        from diffusers import EulerDiscreteScheduler
        try:
            pipe.scheduler = EulerDiscreteScheduler.from_config(
                pipe.scheduler.config)
        except Exception:
            pass
    except Exception as e:
        print(f"[Test] Scheduler ayarı: {e}")

    skorlar = []
    for prompt_t in _TEST_PROMPTLARI:
        try:
            prompt = prompt_t.format(trg=tetikleyici)
            with torch.no_grad():
                res = pipe(
                    prompt=prompt,
                    num_inference_steps=25,
                    guidance_scale=5.0,
                    width=1024, height=1024,
                ).images[0]
            arr = np.array(res)[:, :, ::-1]
            yuzler = face_app.get(arr)
            if not yuzler:
                continue
            gen_emb = yuzler[0].normed_embedding
            # Referansların hepsiyle cosine, max al
            sims = ref_arr @ gen_emb  # normed → cosine
            skorlar.append(float(sims.max()))
        except Exception as e:
            print(f"[Test] Prompt hata: {e}")
            continue

    if not skorlar:
        sonuc["uyarilar"].append("Hiçbir testte yüz tespit edilemedi")
        sonuc["karar"] = "TEST_BASARISIZ"
        return sonuc

    arr = np.array(skorlar)
    sonuc["kimlik_skoru"] = float(arr.mean())
    sonuc["kimlik_min"] = float(arr.min())
    sonuc["kimlik_std"] = float(arr.std())
    sonuc["test_sayisi"] = len(skorlar)

    # Karar
    s = sonuc["kimlik_skoru"]
    if s >= 0.60:
        sonuc["karar"] = "URETIME_HAZIR"
    elif s >= 0.45:
        sonuc["karar"] = "IYI"
    elif s >= 0.30:
        sonuc["karar"] = "ZAYIF_KIMLIK"
        sonuc["uyarilar"].append("Kimlik benzerliği zayıf - daha fazla "
                                  "adım veya daha çeşitli foto dene")
    else:
        sonuc["karar"] = "TEKRAR_EGIT"
        sonuc["uyarilar"].append("Karakter LoRA tutmuyor - veri seti "
                                  "veya parametreler hatalı")

    # Overfit (yüksek skor + düşük varyans = aynı yüzü zorla üretiyor)
    if s > 0.75 and sonuc["kimlik_std"] < 0.05:
        sonuc["uyarilar"].append("Olası overfit - çeşitlilik düşük")

    return sonuc
