"""Qwen3-TTS HTTP server â€” OpenAI-compatible speech endpoint with voice training."""
import argparse, io, json, os, re, sys, tempfile, time
from pathlib import Path
from flask import Flask, request, send_file, jsonify

app = Flask(__name__)
MODEL_DIR = "/models/qwen3-tts"
CUSTOM_VOICES_DIR = "/models/qwen3-tts/custom_speakers"
engine = None

PRESET_SPEAKERS = ["Vivian","Serena","uncle_fu","Dylan","Eric","Ryan","Aiden","ono_anna","Sohee"]
LANGUAGES = ["english","chinese","japanese","korean","german","spanish","french","russian","italian","portuguese"]
SAFE_NAME_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')

# â”€â”€ Bootstrap helpers â”€â”€

def ensure_embeddings(model_dir):
    emb_dir = os.path.join(model_dir, "embeddings")
    if os.path.exists(os.path.join(emb_dir, "text_embedding_projected.npy")):
        return
    gguf_path = os.path.join(model_dir, "qwen3_assets.gguf")
    if not os.path.exists(gguf_path):
        print(f"[server] WARNING: {gguf_path} not found", flush=True)
        return
    print(f"[server] Extracting embeddings from GGUF...", flush=True)
    import numpy as np
    from gguf import GGUFReader
    NAME_MAP = {"text_embd": "text_embedding_projected.npy",
                "proj.weight": "proj_weight.npy", "proj.bias": "proj_bias.npy"}
    for j in range(16):
        NAME_MAP[f"codec_embd.{j}"] = f"codec_embedding_{j}.npy"
    reader = GGUFReader(gguf_path)
    os.makedirs(emb_dir, exist_ok=True)
    for tensor in reader.tensors:
        outname = NAME_MAP.get(tensor.name)
        if outname is None:
            continue
        data = tensor.data
        shape = list(tensor.shape)
        n_el = 1
        for d in shape:
            n_el *= d
        n_blocks = n_el // 32
        raw = data.tobytes()
        block_sz = 34
        if len(raw) == n_blocks * block_sz:
            quants = np.zeros(n_el, dtype=np.float32)
            for bi in range(n_blocks):
                off = bi * block_sz
                s = float(np.frombuffer(raw[off:off+2], dtype=np.float16)[0])
                qs = np.frombuffer(raw[off+2:off+block_sz], dtype=np.int8)
                quants[bi*32:(bi+1)*32] = qs.astype(np.float32) * s
            arr = quants.reshape(shape)
        else:
            arr = np.array(data, dtype=np.float32).reshape(shape)
        np.save(os.path.join(emb_dir, outname), arr)
    print(f"[server] Embeddings extracted", flush=True)

def ensure_symlinks(model_dir):
    links = {"qwen3_tts_talker.q5_k.gguf": "qwen3_tts_talker.gguf",
             "qwen3_tts_predictor.q8_0.gguf": "qwen3_tts_predictor.gguf",
             "qwen3_tts_decoder.fp16.onnx": "qwen3_tts_decoder.onnx",
             "qwen3_tts_codec_encoder.fp16.onnx": "qwen3_tts_codec_encoder.onnx",
             "qwen3_tts_speaker_encoder.fp16.onnx": "qwen3_tts_speaker_encoder.onnx"}
    for link_name, target in links.items():
        lp = os.path.join(model_dir, link_name)
        tp = os.path.join(model_dir, target)
        if not os.path.exists(lp) and os.path.exists(tp):
            os.symlink(target, lp)

def init_engine():
    global engine
    parent = os.path.dirname(os.path.abspath(MODEL_DIR))
    basename = os.path.basename(MODEL_DIR)
    ensure_embeddings(MODEL_DIR)
    ensure_symlinks(MODEL_DIR)
    os.makedirs(CUSTOM_VOICES_DIR, exist_ok=True)
    os.chdir(parent)
    from qwen3_tts_gguf.inference import TTSEngine
    engine = TTSEngine(model_dir=basename, onnx_provider="CPUExecutionProvider")
    if not engine.ready:
        print(f"[server] WARNING: engine created but not ready", flush=True)

# â”€â”€ Voice management helpers â”€â”€

def list_all_speakers():
    presets = []
    for name in PRESET_SPEAKERS:
        presets.append({"name": name, "type": "preset"})
    customs = []
    if os.path.isdir(CUSTOM_VOICES_DIR):
        for f in sorted(os.listdir(CUSTOM_VOICES_DIR)):
            if f.endswith(".json"):
                vname = f[:-5]
                meta = _load_voice_meta(vname)
                customs.append({"name": vname, "type": "custom",
                                "description": meta.get("description", ""),
                                "created": meta.get("created", "")})
    return presets + customs

def _load_voice_meta(name):
    path = os.path.join(CUSTOM_VOICES_DIR, f"{name}.json")
    if not os.path.exists(path):
        return {}
    with open(path, "r", encoding="utf-8") as f:
        data = json.load(f)
    return {"description": data.get("description", ""),
            "created": data.get("created", ""),
            "ref_text": data.get("text", ""),
            "duration_hint": data.get("duration_hint", 0)}

def _voice_exists(name):
    return os.path.exists(os.path.join(CUSTOM_VOICES_DIR, f"{name}.json"))

def _is_custom_voice(name):
    return name not in [s.lower() for s in PRESET_SPEAKERS] and _voice_exists(name)

def _audio_to_wav(buf):
    import numpy as np, wave as _wave
    buf_bytes = buf.tobytes() if hasattr(buf, 'tobytes') else buf
    return buf_bytes

# â”€â”€ Audio rendering helper â”€â”€

def render_audio(audio_np):
    import numpy as np, wave as _wave
    buf = io.BytesIO()
    with _wave.open(buf, "wb") as wf:
        wf.setnchannels(1)
        wf.setsampwidth(2)
        wf.setframerate(24000)
        pcm = (np.clip(audio_np, -1.0, 1.0) * 32767).astype(np.int16).tobytes()
        wf.writeframes(pcm)
    buf.seek(0)
    return buf

# â”€â”€ Routes â”€â”€

@app.route("/health")
def health():
    ready = engine is not None and engine.ready
    speakers = [s["name"] for s in list_all_speakers()]
    return jsonify({"status": "ok" if ready else "loading",
                    "speakers": speakers, "languages": LANGUAGES})

@app.route("/v1/voices", methods=["GET"])
def voices_list():
    return jsonify({"voices": list_all_speakers()})

@app.route("/v1/voices/<name>", methods=["GET"])
def voices_get(name):
    name = name.lower()
    if name in [s.lower() for s in PRESET_SPEAKERS]:
        return jsonify({"name": name, "type": "preset"})
    if not _voice_exists(name):
        return jsonify({"error": "voice not found"}), 404
    meta = _load_voice_meta(name)
    return jsonify({"name": name, "type": "custom", **meta})

@app.route("/v1/voices/<name>", methods=["DELETE"])
def voices_delete(name):
    name = name.lower()
    if name in [s.lower() for s in PRESET_SPEAKERS]:
        return jsonify({"error": "cannot delete preset voice"}), 400
    path = os.path.join(CUSTOM_VOICES_DIR, f"{name}.json")
    if not os.path.exists(path):
        return jsonify({"error": "voice not found"}), 404
    os.remove(path)
    return jsonify({"deleted": name})

@app.route("/v1/voices/train", methods=["POST"])
def voices_train():
    if engine is None or not engine.ready:
        return jsonify({"error": "engine not ready"}), 503

    if "audio" not in request.files:
        return jsonify({"error": "missing 'audio' file in multipart form"}), 400

    name = request.form.get("name", "").strip().lower()
    if not name or not SAFE_NAME_RE.match(name):
        return jsonify({"error": "invalid name (a-z, 0-9, _, - ; max 64 chars)"}), 400
    if name in [s.lower() for s in PRESET_SPEAKERS]:
        return jsonify({"error": "name conflicts with preset speaker"}), 400

    ref_text = request.form.get("text", "").strip()
    description = request.form.get("description", "").strip()
    language = request.form.get("language", "english").strip().lower()
    if language not in LANGUAGES:
        language = "english"

    audio_file = request.files["audio"]
    allowed_ext = {".wav", ".mp3", ".flac", ".m4a", ".opus", ".ogg"}
    ext = os.path.splitext(audio_file.filename or "upload.wav")[1].lower()
    if ext not in allowed_ext:
        return jsonify({"error": f"unsupported format: {ext}"}), 400

    try:
        import numpy as np
        from qwen3_tts_gguf.inference.utils.audio import load_audio
        from qwen3_tts_gguf.inference import TTSConfig
        from qwen3_tts_gguf.inference.schema.result import TTSResult

        with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp:
            audio_file.save(tmp)
            tmp_path = tmp.name

        samples = load_audio(tmp_path)
        os.unlink(tmp_path)

        if samples is None or len(samples) < 2400:
            return jsonify({"error": "audio too short (min 0.1s at 24kHz)"}), 400

        duration = len(samples) / 24000.0
        if duration > 30.0:
            samples = samples[:int(30.0 * 24000)]
            duration = 30.0

        codes = engine.codec_encoder.encode(samples)
        spk_emb = engine.speaker_encoder.encode(samples)

        text_ids = engine.tokenizer.encode(ref_text).ids if ref_text else []

        result = TTSResult(
            text=ref_text,
            text_ids=text_ids,
            codes=codes,
            spk_emb=spk_emb,
            audio=samples
        )

        voice_data = {
            "name": name,
            "description": description,
            "language": language,
            "created": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
            "duration_hint": round(duration, 2),
            "text": ref_text,
            "text_ids": text_ids,
            "codes": codes.tolist(),
            "spk_emb": result.spk_emb.tolist(),
        }

        out_path = os.path.join(CUSTOM_VOICES_DIR, f"{name}.json")
        with open(out_path, "w", encoding="utf-8") as f:
            json.dump(voice_data, f, ensure_ascii=False)

        preview_audio = None
        if ref_text:
            stream = engine.create_stream(n_ctx=2048)
            if stream is not None:
                stream.set_voice(result)
                clone_result = stream.clone(text=ref_text, language=language, config=TTSConfig())
                stream.join()
                if clone_result and clone_result.audio is not None:
                    preview_audio = clone_result.audio

        resp = {"name": name, "type": "custom", "description": description,
                "duration": round(duration, 2), "spk_emb_dim": len(spk_emb),
                "codes_frames": len(codes)}

        if preview_audio is not None:
            wav_buf = render_audio(preview_audio)
            resp_json = json.dumps(resp)
            return send_file(wav_buf, mimetype="audio/wav", download_name=f"{name}_preview.wav",
                             as_attachment=False), 200, {"X-Voice-Info": resp_json}

        return jsonify(resp), 201

    except Exception as e:
        return jsonify({"error": str(e)}), 500

@app.route("/v1/audio/speech", methods=["POST"])
def speech():
    if engine is None or not engine.ready:
        return jsonify({"error": "engine not ready"}), 503
    data = request.get_json(force=True)
    text = data.get("input", "")
    if not text:
        return jsonify({"error": "missing input"}), 400

    voice_name = data.get("voice", "Vivian")
    language = data.get("language", "english")
    if language not in LANGUAGES:
        language = "english"
    instruct = data.get("instruct", "")

    try:
        import numpy as np
        from qwen3_tts_gguf.inference import TTSConfig
        from qwen3_tts_gguf.inference.schema.result import TTSResult

        stream = engine.create_stream(n_ctx=2048)
        if stream is None:
            return jsonify({"error": "failed to create stream"}), 500

        cfg = TTSConfig()
        voice_json = os.path.join(CUSTOM_VOICES_DIR, f"{voice_name.lower()}.json")

        if os.path.exists(voice_json):
            with open(voice_json, "r", encoding="utf-8") as f:
                vdata = json.load(f)
            spk_emb = np.array(vdata["spk_emb"], dtype=np.float32)
            codes = np.array(vdata["codes"], dtype=np.int64)
            anchor = TTSResult(
                text=vdata.get("text", ""),
                text_ids=vdata.get("text_ids", []),
                codes=codes,
                spk_emb=spk_emb
            )
            stream.set_voice(anchor)
            result = stream.clone(text=text, language=language, config=cfg)
        else:
            speaker = voice_name
            if speaker not in PRESET_SPEAKERS:
                speaker = "Vivian"
            result = stream.custom(text=text, speaker=speaker, language=language,
                                   instruct=instruct, config=cfg)

        stream.join()
        if result is None:
            return jsonify({"error": "synthesis returned None"}), 500

        audio = result.audio if hasattr(result, 'audio') and result.audio is not None else None
        if audio is None:
            tmp = os.path.join("/tmp", "tts_out.wav")
            result.save(tmp)
            import soundfile as sf
            audio, _ = sf.read(tmp, dtype='float32')
            os.remove(tmp)

        return send_file(render_audio(audio), mimetype="audio/wav", download_name="speech.wav")
    except Exception as e:
        return jsonify({"error": str(e)}), 500

if __name__ == "__main__":
    p = argparse.ArgumentParser()
    p.add_argument("--host", default="0.0.0.0")
    p.add_argument("--port", type=int, default=8072)
    p.add_argument("--model-dir", default=MODEL_DIR)
    a = p.parse_args()
    MODEL_DIR = a.model_dir
    CUSTOM_VOICES_DIR = os.path.join(MODEL_DIR, "custom_speakers")
    init_engine()
    app.run(host=a.host, port=a.port)
