"""
训练轻量 Transformer 记忆模型，导出 weights.json（NumPy 推理）。

用法:
  cd backend && source venv/bin/activate
  pip install torch numpy   # 训练仅需本机安装
  python -m scripts.train_memory_transformer
  python -m scripts.train_memory_transformer --epochs 30 --from-db
"""

from __future__ import annotations

import argparse
import math
import sys
from datetime import datetime, timezone
from pathlib import Path

import numpy as np

BACKEND = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(BACKEND))

from database import SessionLocal  # noqa: E402
from models import QuizRecord, Word  # noqa: E402
from services.memory_transformer.encoding import (  # noqa: E402
    FEATURE_DIM,
    MAX_SEQ_LEN,
    build_sequence_matrix,
)
from services.memory_transformer.network import MiniTransformer, init_random_weights  # noqa: E402
from services.memory_transformer.service import (  # noqa: E402
    DEFAULT_WEIGHTS_PATH,
    WEIGHTS_PATH,
)

try:
    import torch
    import torch.nn as nn
except ImportError:
    torch = None  # type: ignore
    nn = None  # type: ignore


def _build_torch_model():
    class TorchMiniTransformer(nn.Module):
        def __init__(self, d_model=48, n_heads=2, d_ff=96, n_layers=2):
            super().__init__()
            self.d_model = d_model
            self.n_heads = n_heads
            self.n_layers = n_layers
            self.in_proj = nn.Linear(FEATURE_DIM, d_model)
            self.ln_in = nn.LayerNorm(d_model)
            self.layers = nn.ModuleList(
                [
                    nn.TransformerEncoderLayer(
                        d_model=d_model,
                        nhead=n_heads,
                        dim_feedforward=d_ff,
                        batch_first=True,
                        dropout=0.1,
                    )
                    for _ in range(n_layers)
                ]
            )
            self.head = nn.Linear(d_model, 1)

        def forward(self, x, valid_lens):
            h = self.ln_in(self.in_proj(x))
            key_padding = torch.zeros(
                x.size(0), MAX_SEQ_LEN, dtype=torch.bool, device=x.device
            )
            for i, vl in enumerate(valid_lens):
                if vl < MAX_SEQ_LEN:
                    key_padding[i, vl:] = True
            for layer in self.layers:
                h = layer(h, src_key_padding_mask=key_padding)
            cls = h[:, 0, :]
            return self.head(cls).squeeze(-1)

    return TorchMiniTransformer()


def export_torch_to_numpy(torch_model) -> MiniTransformer:
    """将 PyTorch 权重映射到 NumPy MiniTransformer 命名。"""
    m = MiniTransformer(
        d_model=torch_model.d_model,
        n_heads=torch_model.n_heads,
        d_ff=96,
        n_layers=torch_model.n_layers,
    )
    w = m.weights
    w["in_proj"] = torch_model.in_proj.weight.detach().cpu().numpy().T
    w["in_bias"] = torch_model.in_proj.bias.detach().cpu().numpy()
    w["ln_in_g"] = torch_model.ln_in.weight.detach().cpu().numpy()
    w["ln_in_b"] = torch_model.ln_in.bias.detach().cpu().numpy()

    for li, layer in enumerate(torch_model.layers):
        attn = layer.self_attn
        d = m.d_model
        # PyTorch MHA: in_proj_weight stacks Q,K,V
        in_w = attn.in_proj_weight.detach().cpu().numpy()
        Wq, Wk, Wv = in_w[:d], in_w[d : 2 * d], in_w[2 * d :]
        w[f"L{li}.Wq"] = Wq.T
        w[f"L{li}.Wk"] = Wk.T
        w[f"L{li}.Wv"] = Wv.T
        in_b = attn.in_proj_bias.detach().cpu().numpy()
        w[f"L{li}.Bq"] = in_b[:d]
        w[f"L{li}.Bk"] = in_b[d : 2 * d]
        w[f"L{li}.Bv"] = in_b[2 * d :]
        w[f"L{li}.Wo"] = attn.out_proj.weight.detach().cpu().numpy().T
        w[f"L{li}.Bo"] = attn.out_proj.bias.detach().cpu().numpy()
        w[f"L{li}.ln1_g"] = layer.norm1.weight.detach().cpu().numpy()
        w[f"L{li}.ln1_b"] = layer.norm1.bias.detach().cpu().numpy()
        w[f"L{li}.W1"] = layer.linear1.weight.detach().cpu().numpy().T
        w[f"L{li}.b1"] = layer.linear1.bias.detach().cpu().numpy()
        w[f"L{li}.W2"] = layer.linear2.weight.detach().cpu().numpy().T
        w[f"L{li}.b2"] = layer.linear2.bias.detach().cpu().numpy()
        w[f"L{li}.ln2_g"] = layer.norm2.weight.detach().cpu().numpy()
        w[f"L{li}.ln2_b"] = layer.norm2.bias.detach().cpu().numpy()

    w["head_w"] = torch_model.head.weight.detach().cpu().numpy()[0]
    w["head_b"] = torch_model.head.bias.detach().cpu().numpy()[0]
    return m


def load_samples_from_db(limit: int = 50000) -> tuple[list, list]:
    db = SessionLocal()
    try:
        records = (
            db.query(QuizRecord)
            .order_by(QuizRecord.created_at.asc())
            .limit(limit)
            .all()
        )
        word_cache: dict[int, Word] = {}
        xs, ys = [], []
        for r in records:
            if r.word_id not in word_cache:
                word_cache[r.word_id] = db.query(Word).filter(Word.id == r.word_id).first()
            word = word_cache.get(r.word_id)
            if not word:
                continue
            hist = (
                db.query(QuizRecord)
                .filter(QuizRecord.word_id == r.word_id, QuizRecord.created_at < r.created_at)
                .order_by(QuizRecord.created_at.asc())
                .all()
            )
            seq, vl = build_sequence_matrix(word, hist, parse_iso(r.created_at))
            xs.append(seq)
            ys.append(1.0 if r.is_correct else 0.0)
        return xs, ys
    finally:
        db.close()


def parse_iso(s: str) -> datetime:
    s = s.replace("Z", "+00:00")
    try:
        return datetime.fromisoformat(s)
    except ValueError:
        return datetime.strptime(s[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)


def synthetic_samples(n: int = 4000) -> tuple[list, list]:
    rng = np.random.default_rng(0)
    xs, ys = [], []
    for _ in range(n):
        vl = int(rng.integers(2, 12))
        seq = rng.normal(0, 0.5, (MAX_SEQ_LEN, FEATURE_DIM)).tolist()
        seq[0][0] = rng.uniform(0, 1)
        seq[0][1] = rng.uniform(0, 1)
        last_ok = seq[-1][0] if vl > 1 else 0.5
        y = 1.0 if rng.random() < 0.4 + 0.4 * last_ok + 0.1 * seq[0][0] else 0.0
        xs.append(seq)
        ys.append(y)
    return xs, ys


def train_and_export(
    epochs: int = 20,
    batch_size: int = 64,
    from_db: bool = False,
    out_path: Path = WEIGHTS_PATH,
) -> None:
    if torch is None:
        print("请先安装 PyTorch: pip install torch")
        print("将写入随机初始化 default_weights 供推理回退…")
        m = MiniTransformer()
        m.weights = init_random_weights()
        DEFAULT_WEIGHTS_PATH.parent.mkdir(parents=True, exist_ok=True)
        m.save_json(DEFAULT_WEIGHTS_PATH)
        print(f"已保存 {DEFAULT_WEIGHTS_PATH}")
        return

    xs, ys = load_samples_from_db() if from_db else synthetic_samples()
    if len(xs) < 32:
        print("样本不足，使用合成数据")
        xs, ys = synthetic_samples()

    X = torch.tensor(xs, dtype=torch.float32)
    y = torch.tensor(ys, dtype=torch.float32)
    valid_lens = [min(MAX_SEQ_LEN, sum(1 for row in s if any(v != 0 for v in row))) for s in xs]
    for i, s in enumerate(xs):
        vl = 1
        for j in range(1, MAX_SEQ_LEN):
            if any(v != 0 for v in s[j]):
                vl = j + 1
        valid_lens[i] = max(1, vl)

    model = _build_torch_model()
    opt = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4)
    loss_fn = nn.BCEWithLogitsLoss()

    n = len(xs)
    for ep in range(epochs):
        perm = torch.randperm(n)
        total_loss = 0.0
        steps = 0
        for start in range(0, n, batch_size):
            idx = perm[start : start + batch_size]
            bx = X[idx]
            by = y[idx]
            vl_batch = [valid_lens[i] for i in idx.tolist()]
            opt.zero_grad()
            logits = model(bx, vl_batch)
            loss = loss_fn(logits, by)
            loss.backward()
            opt.step()
            total_loss += loss.item()
            steps += 1
        acc = 0.0
        with torch.no_grad():
            pred = (torch.sigmoid(model(X, valid_lens)) > 0.5).float()
            acc = (pred == y).float().mean().item()
        print(f"epoch {ep + 1}/{epochs} loss={total_loss / max(steps, 1):.4f} acc={acc:.3f}")

    numpy_model = export_torch_to_numpy(model)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    numpy_model.save_json(out_path)
    print(f"已导出 {out_path}")


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--epochs", type=int, default=20)
    parser.add_argument("--from-db", action="store_true")
    parser.add_argument("--out", type=str, default="")
    args = parser.parse_args()
    out = Path(args.out) if args.out else WEIGHTS_PATH
    train_and_export(epochs=args.epochs, from_db=args.from_db, out_path=out)


if __name__ == "__main__":
    main()
