"""
LINE ↔ Cooper 帳號綁定流程(階段 6b)。

API 對外:
  - generate_code(user)        : 在 DB 產一筆 6 位數綁定碼,TTL 10 分鐘
  - redeem_code(code, line_user_id, src_db_user)
                                : 把 LINE shadow user 的 line_user_id 搬到網頁 user
                                  shadow user 軟刪;訊息歸屬全搬到網頁 user
  - parse_bind_command(text)   : 從 LINE 訊息抓「綁定 482593」/ 「/bind 482593」格式

設計重點:
  - 重產的 code 不會 invalidate 之前的 code(那筆只要還沒 used + 沒過期就能用)
  - redeem 內整段在同一 transaction(避免半搬)
  - 若 web user 已有 line_user_id → 覆蓋;原 line_user_id 解除(Cooper 不會綁兩個帳號)
  - 若 LINE shadow user 不存在(user 還沒對 Cooper 講過話)→ redeem 失敗,提示「先傳一句話再綁」
"""

from __future__ import annotations

import logging
import re
import secrets
from datetime import datetime, timedelta, timezone
from uuid import UUID

from sqlalchemy import select, update as sql_update

from app.models.channel import Message
from app.models.line_binding import LineBindingCode
from app.models.user import User


logger = logging.getLogger("coba.line.binding")


CODE_TTL_MINUTES = 10


# 接受三種寫法,後面緊跟 6 位 ASCII 數字。
# 注意 ^...$ 用 strip 後的整串(避免「綁定 482593 額外字」誤觸)。
_BIND_RE = re.compile(
    r"^(?:/?bind|綁定|綁定碼|繫結)\s*[:：]?\s*(\d{6})\s*$",
    re.IGNORECASE,
)


def parse_bind_command(text: str) -> str | None:
    """從訊息抓 6 位數綁定碼;沒 match 回 None。"""
    if not text:
        return None
    m = _BIND_RE.match(text.strip())
    if not m:
        return None
    return m.group(1)


def _gen_code() -> str:
    """6 位數,allow leading zero(secrets 取 0~999999)。"""
    return f"{secrets.randbelow(1_000_000):06d}"


async def generate_code(db, user_id: UUID) -> LineBindingCode:
    """產一張新的綁定碼。
    - 若 6 位數隨機撞到既存 unused & not-expired,自動 retry 最多 5 次
      (10^6 空間,撞機率近 0)
    - 接 UUID 而非 User 物件,避免跨 session lazy-load 失敗
    """
    now = datetime.now(timezone.utc)
    expires_at = now + timedelta(minutes=CODE_TTL_MINUTES)
    last_err: Exception | None = None
    for _ in range(5):
        code = _gen_code()
        existing = (await db.execute(
            select(LineBindingCode).where(
                LineBindingCode.code == code,
                LineBindingCode.used_at.is_(None),
                LineBindingCode.expires_at > now,
            )
        )).scalar_one_or_none()
        if existing is not None:
            continue
        # 顯式塞 created_at/updated_at,避免 prod DB 缺 server_default 導致 NOT NULL 失敗
        rec = LineBindingCode(
            code=code,
            user_id=user_id,
            expires_at=expires_at,
            created_at=now,
            updated_at=now,
        )
        db.add(rec)
        try:
            await db.flush()
        except Exception as e:
            last_err = e
            await db.rollback()
            continue
        return rec
    if last_err:
        raise last_err
    raise RuntimeError("產綁定碼失敗")


class BindingError(Exception):
    """redeem 失敗時用,訊息直接給使用者看。"""


async def redeem_code(db, code: str, line_user_id: str) -> User:
    """把 line_user_id 綁到 code.user_id 上,回傳綁好的 web user。

    這個函式會跑這幾件事(同 transaction):
      1. 確認 code 存在 + 沒過期 + 沒 used
      2. 找 web user(目標)
      3. 找 LINE shadow user(目前 line_user_id 對應的)
      4. 如果 shadow == web user → 早就綁好,no-op 成功
      5. 否則:
         - 把 shadow 的所有 message.user_id 搬到 web user
         - 清掉 web user 既有 line_user_id(萬一已綁別的)+ 設成新的
         - shadow user 軟刪 + 把 line_user_id=NULL(避免 unique constraint)
      6. 標記 code.used_at
    """
    now = datetime.now(timezone.utc)
    rec = (await db.execute(
        select(LineBindingCode).where(LineBindingCode.code == code)
    )).scalar_one_or_none()
    if rec is None:
        raise BindingError("綁定碼不存在,去網頁版重新產一個")
    if rec.used_at is not None:
        raise BindingError("這個綁定碼已經用過了,去網頁版重產一個")
    # SQLite 讀回來會是 offset-naive datetime,跟 utc-aware now 不能比;補 tzinfo
    expires_at = rec.expires_at
    if expires_at.tzinfo is None:
        expires_at = expires_at.replace(tzinfo=timezone.utc)
    if expires_at <= now:
        raise BindingError("綁定碼過期了(10 分鐘),去網頁版重產一個")

    web_user = (await db.execute(
        select(User).where(User.id == rec.user_id, User.deleted_at.is_(None))
    )).scalar_one_or_none()
    if web_user is None:
        raise BindingError("綁定碼對應的網頁帳號不存在(可能被砍了?)")

    shadow = (await db.execute(
        select(User).where(User.line_user_id == line_user_id, User.deleted_at.is_(None))
    )).scalar_one_or_none()

    if shadow is None:
        # LINE 端沒影子帳號(理論上不會發生,因為 message 進來時就建了)
        # 直接把 line_user_id 設到 web user 上
        # 先確認別人沒綁同 line_user_id
        if web_user.line_user_id and web_user.line_user_id != line_user_id:
            web_user.line_user_id = None  # 解除舊綁
            await db.flush()
        web_user.line_user_id = line_user_id
        rec.used_at = now
        await db.flush()
        return web_user

    if shadow.id == web_user.id:
        # 已經綁好(同一個 user),no-op
        rec.used_at = now
        await db.flush()
        return web_user

    # 防呆:LINE 端目前已對到一個「真人帳號」(有設密碼),不能被覆寫合併
    # 影子帳號 password_hash 一律是空字串 ""(line_bridge._get_or_create_user 建立時的設定)
    # 真人帳號 password_hash 是 bcrypt 雜湊,有長度
    if shadow.password_hash:
        raise BindingError(
            "你的 LINE 已經綁定到另一個帳號。請先到那個帳號解除綁定,再用新碼綁過來。"
        )

    # 真實合併:shadow → web_user
    # 1. 訊息搬家
    moved = await db.execute(
        sql_update(Message)
        .where(Message.user_id == shadow.id)
        .values(user_id=web_user.id)
    )
    moved_count = moved.rowcount or 0

    # 2. shadow 釋放 line_user_id(避免 unique 衝突),軟刪
    shadow.line_user_id = None
    shadow.deleted_at = now
    shadow.is_active = False
    await db.flush()

    # 3. web user 設成新 line_user_id
    if web_user.line_user_id and web_user.line_user_id != line_user_id:
        # 解除原綁(罕見,但可能)
        logger.info("web user %s 原已綁 LINE %s,改綁 %s", web_user.id, web_user.line_user_id, line_user_id)
    web_user.line_user_id = line_user_id

    # 4. code 標記用掉
    rec.used_at = now

    await db.flush()
    logger.info(
        "LINE binding 完成 user=%s line_id=%s shadow=%s 搬訊息 %d 則",
        web_user.id, line_user_id, shadow.id, moved_count,
    )
    return web_user


async def unbind(db, user_id: UUID) -> bool:
    """解除某個 web user 的 LINE 綁定。

    清掉 user.line_user_id 即可。「過去的訊息歸屬」不變動(已經 reassign 過,屬於 web user)。
    日後若同一個 LINE userId 再來訊息,會被當成新人,自動建影子帳號。
    回傳:True = 有解綁成功;False = 原本就沒綁。
    """
    u = (await db.execute(
        select(User).where(User.id == user_id, User.deleted_at.is_(None))
    )).scalar_one_or_none()
    if u is None or not u.line_user_id:
        return False
    logger.info("LINE unbind: user=%s 解除原 line_user_id=%s", u.id, u.line_user_id)
    u.line_user_id = None
    await db.flush()
    return True
