# -*- coding: utf-8 -*-
"""
租户管理后台 — 用户审核 + 用户管理路由。

端点清单：
- GET  /api/tenant/my-sites                            — 管辖店铺列表
- GET  /api/tenant/roles                               — 小程序可用角色列表（动态）
- GET  /api/tenant/site-staff                          — 按角色+门店查询人员候选列表
- GET  /api/tenant/applications                        — 申请列表（status 筛选 + 分页）
- GET  /api/tenant/applications/{id}/match-suggestions — 关联匹配建议
- POST /api/tenant/applications/{id}/approve           — 审核通过
- POST /api/tenant/applications/{id}/reject            — 审核拒绝
- GET  /api/tenant/users                               — 用户列表（角色筛选 + 搜索 + 分页）
- PATCH /api/tenant/users/{id}                         — 编辑用户（角色+门店+绑定合并）
- DELETE /api/tenant/users/{id}                        — 移除用户

需求: 3.1-3.6, 4.1-4.5

AI_CHANGELOG
- 2026-03-23 17:00:00 | Prompt: P20260323-164500（审核弹窗改造：角色动态化+人员联动）| Direct cause：角色硬编码无法适应增减，人员列表无联动查询 | Summary：新增 GET /roles（auth.roles 动态读取）+ GET /site-staff（ETL 直连查 dim_assistant/dim_staff）| Verify：tenant-admin 审核弹窗角色下拉动态加载 + 人员联动 + 手机号匹配
- 2026-03-24 | Prompt: 用户管理绑定功能改造 | Direct cause：EditModal+BindModal 分离，BindModal 手动输入 ID 体验差 | Summary：PATCH /users/{id} 合并角色+绑定提交，换角色自动清除旧绑定，互斥校验（coach→助教，其他→员工）；移除 PUT /binding 端点清单引用 | Verify：编辑弹窗角色联动人员列表 + 解绑 + 换角色清除旧绑定
- 2026-03-23 19:30:00 | Prompt: P20260323-190000（手机号不显示+自动匹配+身份标签中文化）| Direct cause：list_applications 用 u.phone（可能 null）；identity_label 显示数字而非中文 | Summary：SQL phone 改为 ua.phone；助教 identity_label 从 cfg_assistant_level_price 读中文等级名；员工用 job 字段 | Verify：Playwright 验证手机号显示+身份标签中文+入职日期前缀
- 2026-03-23 20:00:00 | Prompt: P20260323-200000（店铺筛选+时间格式+姓名格式+李小燕）| Direct cause：list_my_sites 只查 managed_site_ids 不含新建店铺；created_at::text 含微秒+时区；助教只查 real_name 无 nickname | Summary：list_my_sites tenant_admin 按 tenant_id 查所有店铺；created_at 改 to_char；助教加 nickname 组装"昵称（真实姓名）"；entry_time 改 to_char | Verify：Playwright 验证店铺下拉含 2 店+时间精确到秒+姓名格式+李小燕可搜到
- 2026-03-23 21:00:00 | Prompt: P20260323-210000（根治 tenant_admin managed_site_ids 限制）| Direct cause：JWT managed_site_ids 静态，新建店铺后所有端点受限 | Summary：所有 verify_site_access/site_filter_clause 调用改用 admin=admin 参数，自动区分 tenant_admin（查库）/site_admin（用 JWT）；回退 list_my_sites/list_applications 的头痛医头代码 | Verify：tenant-admin 所有功能覆盖新建店铺
- 2026-03-24 | Prompt: 审核弹窗头像昵称+排版优化 | Direct cause：list_applications SQL 未查 avatar_url | Summary：SELECT 新增 u.avatar_url，ApplicationListItem 构造新增 avatar_url=r[9] | Verify：GET /applications 返回 avatarUrl 字段
"""

from __future__ import annotations

import logging
from typing import Optional

from fastapi import APIRouter, Depends, HTTPException, Query, status

from app.auth.tenant_admins import (
    CurrentTenantAdmin,
    get_effective_site_ids,
    require_tenant_admin,
    site_filter_clause,
    verify_site_access,
)
from app.database import get_connection, get_etl_readonly_connection
from app.schemas.tenant_users import (
    ApplicationListItem,
    ApproveRequest,
    MatchSuggestion,
    RejectRequest,
    RoleItem,
    StaffCandidate,
    UserBindingRequest,
    UserEditRequest,
    UserListItem,
)

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/api/tenant", tags=["租户用户管理"])


def _format_display_name(nickname: str | None, real_name: str | None) -> str:
    """组装显示姓名：昵称（真实姓名）。"""
    nick = (nickname or "").strip()
    real = (real_name or "").strip()
    if nick and real and nick != real:
        return f"{nick}（{real}）"
    return real or nick or ""


# ── GET /api/tenant/my-sites ──────────────────────────────


@router.get("/my-sites")
async def list_my_sites(
    admin: CurrentTenantAdmin = Depends(require_tenant_admin),
):
    """返回当前管理员管辖的店铺列表（用于前端筛选下拉）。

    通过 get_effective_site_ids 统一获取有效 site_ids：
    - tenant_admin：实时查 biz.sites（覆盖新建店铺）
    - site_admin：使用 JWT 中的 managed_site_ids
    """
    # [CHANGE P20260323-210000] intent: 使用根治方案 get_effective_site_ids，
    # 替代之前的 tenant_admin/site_admin 分支硬编码
    effective_ids = get_effective_site_ids(admin)
    if not effective_ids:
        return []

    conn = get_connection()
    try:
        with conn.cursor() as cur:
            placeholders = ", ".join(["%s"] * len(effective_ids))
            cur.execute(
                f"""
                SELECT site_id, site_name, site_code
                  FROM biz.sites
                 WHERE site_id IN ({placeholders}) AND is_active = true
                 ORDER BY site_name
                """,
                tuple(effective_ids),
            )
            rows = cur.fetchall()
    finally:
        conn.close()

    return [
        {"siteId": r[0], "siteName": r[1], "siteCode": r[2]}
        for r in rows
    ]


# ── GET /api/tenant/roles ──────────────────────────────────
# [CHANGE P20260323-164500] intent: 角色列表从 auth.roles 动态读取，排除管理类角色
# assumptions: auth.roles 表已有 coach/staff/head_coach/manager 四条小程序角色
# verify: GET /api/tenant/roles 返回 4 个角色项


@router.get("/roles")
async def list_roles(
    admin: CurrentTenantAdmin = Depends(require_tenant_admin),
):
    """返回小程序可用角色列表（从 auth.roles 动态读取，排除管理类角色）。"""
    # 排除管理类角色（tenant_admin / site_admin），只返回小程序端角色
    excluded_codes = ("tenant_admin", "site_admin")

    conn = get_connection()
    try:
        with conn.cursor() as cur:
            placeholders = ", ".join(["%s"] * len(excluded_codes))
            cur.execute(
                f"""
                SELECT id, code, name, description
                  FROM auth.roles
                 WHERE code NOT IN ({placeholders})
                 ORDER BY id
                """,
                excluded_codes,
            )
            rows = cur.fetchall()
    finally:
        conn.close()

    return [
        RoleItem(id=r[0], code=r[1], name=r[2], description=r[3]).model_dump(by_alias=True)
        for r in rows
    ]


# ── GET /api/tenant/site-staff ────────────────────────────
# [CHANGE P20260323-164500] intent: 按角色+门店查询人员候选列表（coach→dim_assistant，其他→dim_staff）
# assumptions: 不用 FDW 视图（RLS 跨库不生效），直连 ETL 库底层表 + 手动 site_id 过滤
# edge cases: site_code 大小写不敏感匹配；ETL 连接失败返回 500
# verify: 选择 coach 返回助教列表，选择 staff/head_coach/manager 返回员工列表


@router.get("/site-staff")
async def list_site_staff(
    role: str = Query(..., description="角色 code（coach → 查助教表，其他 → 查员工表）"),
    site_id: Optional[int] = Query(None, description="店铺 site_id（上游 BIGINT）"),
    site_code: Optional[str] = Query(None, description="店铺编号（与 site_id 二选一）"),
    admin: CurrentTenantAdmin = Depends(require_tenant_admin),
):
    """根据角色 + site_id 查询对应人员列表。

    - role=coach → 查 ETL 库 dwd.dim_assistant（scd2_is_current=1）
    - 其他 role → 查 ETL 库 dwd.dim_staff（scd2_is_current=1）

    site_id 和 site_code 二选一，优先 site_id。
    使用 get_etl_readonly_connection 直连 ETL 库查底层表，
    手动加 site_id 过滤（FDW 视图的 RLS 在跨库场景下不生效）。
    """
    # 如果传了 site_code 但没传 site_id，先查 biz.sites 获取 site_id
    if site_id is None and site_code:
        conn = get_connection()
        try:
            with conn.cursor() as cur:
                cur.execute(
                    """
                    SELECT site_id FROM biz.sites
                     WHERE UPPER(site_code) = %s AND is_active = true
                     LIMIT 1
                    """,
                    (site_code.upper(),),
                )
                row = cur.fetchone()
                if row is None:
                    raise HTTPException(status_code=404, detail="未找到对应店铺")
                site_id = row[0]
        finally:
            conn.close()

    if site_id is None:
        raise HTTPException(status_code=400, detail="需要提供 site_id 或 site_code")

    # 权限校验：校验 site_id 在管辖范围内
    verify_site_access(site_id, admin=admin)

    try:
        etl_conn = get_etl_readonly_connection(site_id)
        try:
            with etl_conn.cursor() as cur:
                if role == "coach":
                    # [CHANGE P20260323-190000] intent: 助教 identity_label 从配置表读中文等级名，
                    # 而非 level::text（数字）。员工用 job 字段代替 staff_identity 数字。
                    # assumptions: cfg_assistant_level_price 有 level_code→level_name 映射
                    # verify: 弹窗人员下拉显示如 "初级 - 张三 - 手机号 - 入职日期 YYYY-MM-DD"
                    # 先查等级映射配置表（feiqiu-data-rules 规则 6: 禁止硬编码）
                    # CHANGE 2026-05-02 | 用 business_date 替代 CURRENT_DATE，沙箱按当时生效配置
                    from app.services.runtime_context import as_runtime_today_param
                    _ref_date = as_runtime_today_param(site_id)
                    cur.execute(
                        """
                        SELECT DISTINCT level_code, level_name
                          FROM dws.cfg_assistant_level_price
                         WHERE effective_from <= %s::date
                           AND effective_to >= %s::date
                        """,
                        (_ref_date, _ref_date),
                    )
                    level_map = {row[0]: row[1] for row in cur.fetchall()}

                    # [CHANGE P20260323-200000] intent: 加 nickname 字段，
                    # 姓名格式改为 "昵称（真实姓名）"
                    cur.execute(
                        """
                        SELECT assistant_id, level, nickname, real_name, mobile,
                               to_char(entry_time, 'YYYY-MM-DD') AS entry_date
                          FROM dwd.dim_assistant
                         WHERE scd2_is_current = 1 AND site_id = %s
                         ORDER BY entry_time DESC NULLS LAST
                        """,
                        (site_id,),
                    )
                    rows = cur.fetchall()
                    result = [
                        StaffCandidate(
                            id=r[0],
                            identity_label=level_map.get(r[1], str(r[1]) if r[1] is not None else None),
                            name=_format_display_name(r[2], r[3]),
                            mobile=r[4],
                            entry_time=r[5],
                            source="assistant",
                        ).model_dump(by_alias=True)
                        for r in rows
                    ]
                else:
                    cur.execute(
                        """
                        SELECT staff_id, job, staff_name, mobile,
                               to_char(entry_time, 'YYYY-MM-DD') AS entry_date
                          FROM dwd.dim_staff
                         WHERE scd2_is_current = 1 AND site_id = %s
                         ORDER BY entry_time DESC NULLS LAST
                        """,
                        (site_id,),
                    )
                    rows = cur.fetchall()
                    result = [
                        StaffCandidate(
                            id=r[0],
                            identity_label=r[1],
                            name=r[2] or "",
                            mobile=r[3],
                            entry_time=r[4],
                            source="staff",
                        ).model_dump(by_alias=True)
                        for r in rows
                    ]
        finally:
            etl_conn.close()
    except Exception:
        logger.error("查询人员列表失败（role=%s, site_id=%s）", role, site_id, exc_info=True)
        raise HTTPException(status_code=500, detail="查询人员列表失败")

    return result


# ── GET /api/tenant/applications ──────────────────────────


@router.get("/applications")
async def list_applications(
    status_filter: Optional[str] = Query(
        None, alias="status", description="按状态筛选：pending/approved/rejected",
    ),
    site_id: Optional[int] = Query(
        None, description="按店铺筛选（site_id）",
    ),
    page: int = Query(1, ge=1, description="页码"),
    page_size: int = Query(20, ge=1, le=100, description="每页条数"),
    admin: CurrentTenantAdmin = Depends(require_tenant_admin),
):
    """申请列表，status/site_id 筛选 + 分页。排除 cancelled 申请。

    隔离策略：
    - tenant_admin：看到该租户下所有店铺的申请（通过 JOIN biz.sites 按 tenant_id 过滤）
    - site_admin：只看到 managed_site_ids 范围内的申请
    """
    # [CHANGE P20260323-210000] intent: 统一使用 site_filter_clause(admin=admin)，
    # 替代之前的 tenant_admin/site_admin 分支硬编码

    if site_id is not None:
        verify_site_access(site_id, admin=admin)

    offset = (page - 1) * page_size

    conn = get_connection()
    try:
        with conn.cursor() as cur:
            # 构建 WHERE 子句
            if site_id is not None:
                # 精确筛选单个店铺
                where_parts = ["ua.site_id = %s", "ua.status <> 'cancelled'"]
                params: list = [site_id]
            else:
                # 统一按有效 site_ids 过滤（tenant_admin 查库，site_admin 用 JWT）
                site_sql, site_params = site_filter_clause(admin=admin)
                where_parts = [f"ua.{site_sql}", "ua.status <> 'cancelled'"]
                params: list = list(site_params)

            if status_filter:
                where_parts.append("ua.status = %s")
                params.append(status_filter)

            where_clause = " AND ".join(where_parts)

            # 查询总数
            cur.execute(
                f"""
                SELECT COUNT(*)
                  FROM auth.user_applications ua
                 WHERE {where_clause}
                """,
                tuple(params),
            )
            total = cur.fetchone()[0]

            # 查询列表
            # [CHANGE P20260323-190000] intent: phone 改用 ua.phone（user_applications 表，NOT NULL），
            # 原 u.phone（auth.users）可能为 null 导致前端显示 "-"
            cur.execute(
                f"""
                SELECT ua.id, ua.user_id, u.nickname, ua.phone,
                       ua.site_code, ua.applied_role_text,
                       ua.employee_number,
                       to_char(ua.created_at, 'YYYY-MM-DD HH24:MI:SS') AS created_at,
                       ua.status,
                       u.avatar_url
                  FROM auth.user_applications ua
                  LEFT JOIN auth.users u ON u.id = ua.user_id
                 WHERE {where_clause}
                 ORDER BY ua.created_at DESC
                 LIMIT %s OFFSET %s
                """,
                (*params, page_size, offset),
            )
            rows = cur.fetchall()
    finally:
        conn.close()

    items = [
        ApplicationListItem(
            id=r[0], user_id=r[1], nickname=r[2], phone=r[3],
            site_code=r[4], applied_role_text=r[5],
            employee_number=r[6], created_at=r[7], status=r[8],
            avatar_url=r[9],
        ).model_dump(by_alias=True)
        for r in rows
    ]
    return {"items": items, "total": total, "page": page, "pageSize": page_size}


# ── GET /api/tenant/applications/{id}/match-suggestions ───


@router.get("/applications/{application_id}/match-suggestions")
async def get_match_suggestions(
    application_id: int,
    admin: CurrentTenantAdmin = Depends(require_tenant_admin),
):
    """通过 biz.sites 查 site_id，并行匹配助教和员工。"""
    conn = get_connection()
    try:
        with conn.cursor() as cur:
            # 获取申请记录
            cur.execute(
                "SELECT user_id, site_code, phone FROM auth.user_applications WHERE id = %s",
                (application_id,),
            )
            app_row = cur.fetchone()
            if app_row is None:
                raise HTTPException(status_code=404, detail="申请不存在")

            _user_id, site_code, phone = app_row

            # 通过 biz.sites 查 site_id（优先当前活跃编码，再查历史编码）
            cur.execute(
                """
                SELECT site_id FROM biz.sites
                 WHERE site_code = %s AND is_active = true
                UNION ALL
                SELECT s.site_id FROM biz.site_code_history h
                  JOIN biz.sites s ON s.site_id = h.site_id
                 WHERE h.site_code = %s AND h.is_current = false AND s.is_active = true
                 LIMIT 1
                """,
                (site_code, site_code),
            )
            scm_row = cur.fetchone()
    finally:
        conn.close()

    if scm_row is None:
        return []

    site_id = scm_row[0]
    verify_site_access(site_id, admin=admin)

    if not phone:
        return []

    suggestions: list[dict] = []

    # 匹配 v_dim_assistant（FDW，需要 RLS 设置）
    try:
        etl_conn = get_etl_readonly_connection(site_id)
        try:
            with etl_conn.cursor() as cur:
                cur.execute(
                    """
                    SELECT assistant_id, name, number
                      FROM app.v_dim_assistant
                     WHERE phone = %s AND scd2_is_current = 1
                    """,
                    (phone,),
                )
                for row in cur.fetchall():
                    suggestions.append(
                        MatchSuggestion(
                            assistant_id=row[0], name=row[1],
                            number=row[2], source_table="v_dim_assistant",
                        ).model_dump(by_alias=True)
                    )
        finally:
            etl_conn.close()
    except Exception:
        logger.warning("v_dim_assistant 匹配失败（site_id=%s）", site_id, exc_info=True)

    # 匹配 v_dim_staff + v_dim_staff_ex
    try:
        etl_conn = get_etl_readonly_connection(site_id)
        try:
            with etl_conn.cursor() as cur:
                cur.execute(
                    """
                    SELECT s.staff_id, s.name, s.number
                      FROM app.v_dim_staff s
                      LEFT JOIN app.v_dim_staff_ex sx ON sx.staff_id = s.staff_id
                     WHERE s.phone = %s OR sx.phone = %s
                    """,
                    (phone, phone),
                )
                for row in cur.fetchall():
                    suggestions.append(
                        MatchSuggestion(
                            staff_id=row[0], name=row[1],
                            number=row[2], source_table="v_dim_staff",
                        ).model_dump(by_alias=True)
                    )
        finally:
            etl_conn.close()
    except Exception:
        logger.warning("v_dim_staff 匹配失败（site_id=%s）", site_id, exc_info=True)

    return suggestions


# ── POST /api/tenant/applications/{id}/approve ────────────


@router.post("/applications/{application_id}/approve")
async def approve_application(
    application_id: int,
    body: ApproveRequest,
    admin: CurrentTenantAdmin = Depends(require_tenant_admin),
):
    """
    审核通过：事务内多表写入。

    1. 更新 users.status='approved'
    2. 写入 user_site_roles
    3. 写入 user_assistant_binding（如提供 assistant_id/staff_id）
    4. 更新 user_applications.status='approved' + 审核人 + 审核时间
    """
    conn = get_connection()
    try:
        with conn.cursor() as cur:
            # 获取申请记录
            cur.execute(
                "SELECT user_id, site_code, status FROM auth.user_applications WHERE id = %s",
                (application_id,),
            )
            app_row = cur.fetchone()
            if app_row is None:
                raise HTTPException(status_code=404, detail="申请不存在")

            user_id, site_code, app_status = app_row

            # 非 pending 状态拒绝审核
            if app_status != "pending":
                raise HTTPException(status_code=409, detail="该申请已被处理")

            # 查 site_id（优先当前活跃编码，再查历史编码）
            # CHANGE 2026-03-23 | 大小写不敏感匹配 site_code
            site_code_upper = site_code.upper()
            cur.execute(
                """
                SELECT site_id FROM biz.sites
                 WHERE UPPER(site_code) = %s AND is_active = true
                UNION ALL
                SELECT s.site_id FROM biz.site_code_history h
                  JOIN biz.sites s ON s.site_id = h.site_id
                 WHERE UPPER(h.site_code) = %s AND h.is_current = false AND s.is_active = true
                 LIMIT 1
                """,
                (site_code_upper, site_code_upper),
            )
            scm_row = cur.fetchone()
            if scm_row is None:
                raise HTTPException(status_code=400, detail="无效的球房编号")
            site_id = scm_row[0]

            verify_site_access(site_id, admin=admin)

            # 1. 更新 users.status='approved'
            cur.execute(
                "UPDATE auth.users SET status = 'approved' WHERE id = %s",
                (user_id,),
            )

            # 2. 写入 user_site_roles
            # CHANGE 2026-03-23 | BUG: 列名是 role_id（integer FK），不是 role；需先查 auth.roles 把 code 转 id
            # CHANGE 2026-03-23 | BUG: 前端可能传 code（coach）或 name（助教），需同时匹配
            cur.execute(
                "SELECT id FROM auth.roles WHERE code = %s OR name = %s",
                (body.role, body.role),
            )
            role_row = cur.fetchone()
            if role_row is None:
                raise HTTPException(status_code=400, detail=f"无效的角色: {body.role}")
            role_id = role_row[0]

            # CHANGE 2026-03-25 | 软删除恢复：旧记录 is_removed=true 时，ON CONFLICT DO NOTHING 会跳过
            # 导致 approved 用户无角色。改为 upsert：冲突时恢复 is_removed=false
            cur.execute(
                """
                INSERT INTO auth.user_site_roles (user_id, site_id, role_id)
                VALUES (%s, %s, %s)
                ON CONFLICT (user_id, site_id, role_id)
                DO UPDATE SET is_removed = false, removed_at = NULL
                """,
                (user_id, site_id, role_id),
            )

            # 3. 写入 user_assistant_binding（如提供）
            # CHANGE 2026-03-23 | BUG: 表有 site_id（NOT NULL）和 binding_type（NOT NULL），原代码漏传
            if body.assistant_id is not None or body.staff_id is not None:
                binding_type = "assistant" if body.assistant_id is not None else "staff"
                cur.execute(
                    """
                    INSERT INTO auth.user_assistant_binding
                        (user_id, site_id, assistant_id, staff_id, binding_type)
                    VALUES (%s, %s, %s, %s, %s)
                    ON CONFLICT DO NOTHING
                    """,
                    (user_id, site_id, body.assistant_id, body.staff_id, binding_type),
                )

            # 4. 更新 user_applications
            # CHANGE 2026-03-22 | #1 BUG: reviewed_by → reviewer_id（DDL 列名修正）
            # intent: SQL 列名与 auth.user_applications DDL 一致
            cur.execute(
                """
                UPDATE auth.user_applications
                   SET status = 'approved',
                       reviewer_id = %s,
                       reviewed_at = NOW()
                 WHERE id = %s
                """,
                (admin.admin_id, application_id),
            )

        conn.commit()
    except HTTPException:
        conn.rollback()
        raise
    except Exception:
        conn.rollback()
        logger.error("审核通过失败（application_id=%s）", application_id, exc_info=True)
        raise HTTPException(status_code=500, detail="审核操作失败")
    finally:
        conn.close()

    return {"message": "审核通过"}


# ── POST /api/tenant/applications/{id}/reject ─────────────


@router.post("/applications/{application_id}/reject")
async def reject_application(
    application_id: int,
    body: RejectRequest,
    admin: CurrentTenantAdmin = Depends(require_tenant_admin),
):
    """
    审核拒绝：更新 user_applications 状态 + 拒绝原因 + 审核时间。
    累加 rejection_count，第三次自动禁用账号。
    """
    conn = get_connection()
    try:
        with conn.cursor() as cur:
            # 获取申请记录
            cur.execute(
                "SELECT user_id, status, site_code FROM auth.user_applications WHERE id = %s",
                (application_id,),
            )
            app_row = cur.fetchone()
            if app_row is None:
                raise HTTPException(status_code=404, detail="申请不存在")

            app_user_id, app_status, site_code = app_row

            if app_status != "pending":
                raise HTTPException(status_code=409, detail="该申请已被处理")

            # 验证门店权限（优先当前活跃编码，再查历史编码）
            cur.execute(
                """
                SELECT site_id FROM biz.sites
                 WHERE site_code = %s AND is_active = true
                UNION ALL
                SELECT s.site_id FROM biz.site_code_history h
                  JOIN biz.sites s ON s.site_id = h.site_id
                 WHERE h.site_code = %s AND h.is_current = false AND s.is_active = true
                 LIMIT 1
                """,
                (site_code, site_code),
            )
            scm_row = cur.fetchone()
            if scm_row:
                verify_site_access(scm_row[0], admin=admin)

            # CHANGE 2026-03-22 | #1 BUG: reviewed_by → reviewer_id（DDL 列名修正）
            cur.execute(
                """
                UPDATE auth.user_applications
                   SET status = 'rejected',
                       review_note = %s,
                       reviewer_id = %s,
                       reviewed_at = NOW()
                 WHERE id = %s
                """,
                (body.reason, admin.admin_id, application_id),
            )

            # CHANGE 2026-03-23 | 审核流程增强：累加 rejection_count，第三次自动禁用
            cur.execute(
                """
                UPDATE auth.users
                   SET rejection_count = rejection_count + 1,
                       updated_at = NOW()
                 WHERE id = %s
                RETURNING rejection_count
                """,
                (app_user_id,),
            )
            new_count = cur.fetchone()[0]

            if new_count >= 3:
                # 第三次拒绝：自动禁用账号
                cur.execute(
                    "UPDATE auth.users SET status = 'disabled', updated_at = NOW() WHERE id = %s",
                    (app_user_id,),
                )
                logger.warning(
                    "用户 %s 累计被拒绝 %d 次，已自动禁用", app_user_id, new_count,
                )
            else:
                # 未达阈值：回退用户状态为 rejected（允许重新申请）
                cur.execute(
                    "UPDATE auth.users SET status = 'rejected', updated_at = NOW() WHERE id = %s",
                    (app_user_id,),
                )

        conn.commit()
    except HTTPException:
        conn.rollback()
        raise
    except Exception:
        conn.rollback()
        logger.error("审核拒绝失败（application_id=%s）", application_id, exc_info=True)
        raise HTTPException(status_code=500, detail="审核操作失败")
    finally:
        conn.close()

    result = {"message": "已拒绝"}
    if new_count >= 3:
        result["user_disabled"] = True
        result["message"] = "已拒绝，该用户累计被拒绝 3 次，账号已自动禁用"
    return result


# ── GET /api/tenant/users ─────────────────────────────────


@router.get("/users")
async def list_users(
    role: Optional[str] = Query(None, description="按角色筛选"),
    keyword: Optional[str] = Query(None, description="关键词搜索（姓名/手机号）"),
    page: int = Query(1, ge=1, description="页码"),
    page_size: int = Query(20, ge=1, le=100, description="每页条数"),
    admin: CurrentTenantAdmin = Depends(require_tenant_admin),
):
    """已通过审核用户列表，角色筛选 + 关键词搜索 + 分页。"""
    site_sql, site_params = site_filter_clause(admin=admin)
    offset = (page - 1) * page_size

    conn = get_connection()
    try:
        with conn.cursor() as cur:
            where_parts = [f"usr.{site_sql}", "u.status IN ('approved', 'disabled')"]
            params: list = list(site_params)

            if role:
                # CHANGE 2026-03-23 | 角色筛选同时支持 code 和 name
                where_parts.append("(r.code = %s OR r.name = %s)")
                params.extend([role, role])

            if keyword:
                where_parts.append("(u.nickname ILIKE %s OR u.phone ILIKE %s)")
                like_kw = f"%{keyword}%"
                params.extend([like_kw, like_kw])

            where_clause = " AND ".join(where_parts)

            # 总数
            cur.execute(
                f"""
                SELECT COUNT(DISTINCT u.id)
                  FROM auth.users u
                  JOIN auth.user_site_roles usr ON usr.user_id = u.id
                       AND usr.is_removed = false
                  JOIN auth.roles r ON r.id = usr.role_id
                 WHERE {where_clause}
                """,
                tuple(params),
            )
            total = cur.fetchone()[0]

            # 列表
            cur.execute(
                f"""
                SELECT DISTINCT ON (u.id)
                       u.id, u.nickname, r.name AS role, r.code AS role_code,
                       uab.assistant_id, uab.staff_id, usr.site_id, u.status
                  FROM auth.users u
                  JOIN auth.user_site_roles usr ON usr.user_id = u.id
                       AND usr.is_removed = false
                  JOIN auth.roles r ON r.id = usr.role_id
                  LEFT JOIN auth.user_assistant_binding uab ON uab.user_id = u.id
                       AND uab.is_removed = false
                 WHERE {where_clause}
                 ORDER BY u.id DESC
                 LIMIT %s OFFSET %s
                """,
                (*params, page_size, offset),
            )
            rows = cur.fetchall()

            # 批量查 site_name
            site_ids = list({r[6] for r in rows if r[6]})
            site_name_map: dict[int, str] = {}
            if site_ids:
                placeholders = ", ".join(["%s"] * len(site_ids))
                cur.execute(
                    f"SELECT site_id, site_name FROM biz.sites WHERE site_id IN ({placeholders})",
                    tuple(site_ids),
                )
                for sid, sname in cur.fetchall():
                    site_name_map[sid] = sname
    finally:
        conn.close()

    items = [
        UserListItem(
            id=r[0], nickname=r[1], role=r[2], role_code=r[3],
            assistant_id=r[4], staff_id=r[5],
            assistant_name=None,  # 助教姓名需要 FDW 查询，此处简化
            site_name=site_name_map.get(r[6]),
            site_id=r[6], status=r[7],
        ).model_dump(by_alias=True)
        for r in rows
    ]
    return {"items": items, "total": total, "page": page, "pageSize": page_size}


# ── PATCH /api/tenant/users/{id} ──────────────────────────


@router.patch("/users/{user_id}")
async def edit_user(
    user_id: int,
    body: UserEditRequest,
    admin: CurrentTenantAdmin = Depends(require_tenant_admin),
):
    """编辑用户角色/门店 + 绑定（合并接口）。

    - 角色与绑定互斥：coach 只能绑 assistant_id，其他角色只能绑 staff_id
    - 换角色时自动清除旧绑定
    - site_id 超出管辖范围返回 403
    """
    # [CHANGE P20260324] intent: 合并 edit + binding 为单一接口，换角色自动清除旧绑定
    # assumptions: 前端传 role code（coach/staff/head_coach/manager）；staffBinding="none" 时 assistant_id 和 staff_id 均为 None
    # verify: 编辑弹窗提交后角色+绑定同时更新，换角色旧绑定被清除

    # 验证新 site_id 在管辖范围内
    if body.site_id is not None:
        verify_site_access(body.site_id, admin=admin)

    # 校验互斥：coach 只能绑 assistant_id，非 coach 只能绑 staff_id
    if body.role == "coach" and body.staff_id is not None:
        raise HTTPException(status_code=400, detail="助教角色只能关联助教，不能关联员工")
    if body.role is not None and body.role != "coach" and body.assistant_id is not None:
        raise HTTPException(status_code=400, detail="非助教角色只能关联员工，不能关联助教")

    conn = get_connection()
    try:
        with conn.cursor() as cur:
            # 验证用户存在且在管辖范围内
            site_sql, site_params = site_filter_clause(admin=admin)
            cur.execute(
                f"""
                SELECT u.id, u.status
                  FROM auth.users u
                  JOIN auth.user_site_roles usr ON usr.user_id = u.id
                 WHERE u.id = %s AND usr.{site_sql}
                   AND usr.is_removed = false
                 LIMIT 1
                """,
                (user_id, *site_params),
            )
            user_row = cur.fetchone()
            if user_row is None:
                raise HTTPException(status_code=404, detail="用户不存在")

            # 更新角色
            role_changed = False
            if body.role is not None:
                cur.execute(
                    "SELECT id FROM auth.roles WHERE code = %s OR name = %s",
                    (body.role, body.role),
                )
                role_row = cur.fetchone()
                if role_row is None:
                    raise HTTPException(status_code=400, detail=f"无效的角色: {body.role}")
                new_role_id = role_row[0]

                # 检查角色是否真的变了
                cur.execute(
                    "SELECT role_id FROM auth.user_site_roles WHERE user_id = %s AND is_removed = false LIMIT 1",
                    (user_id,),
                )
                old_role_row = cur.fetchone()
                if old_role_row and old_role_row[0] != new_role_id:
                    role_changed = True

                cur.execute(
                    """
                    UPDATE auth.user_site_roles SET role_id = %s
                     WHERE user_id = %s AND is_removed = false AND site_id IN (
                        SELECT site_id FROM auth.user_site_roles WHERE user_id = %s AND is_removed = false LIMIT 1
                     )
                    """,
                    (new_role_id, user_id, user_id),
                )

            # 更新门店
            if body.site_id is not None:
                cur.execute(
                    """
                    UPDATE auth.user_site_roles SET site_id = %s
                     WHERE user_id = %s AND is_removed = false
                    """,
                    (body.site_id, user_id),
                )

            # 更新绑定：只要前端提交了角色字段，就同步更新绑定（含解绑场景）
            # [CHANGE P20260324] fix: 适配 user_assistant_binding 实际表结构
            # 表有 site_id(NOT NULL), binding_type(NOT NULL), is_removed 列
            # 唯一索引是 (user_id, site_id) WHERE is_removed = false
            if body.role is not None:
                new_assistant_id = body.assistant_id
                new_staff_id = body.staff_id

                # 获取用户的 site_id
                cur.execute(
                    """
                    SELECT site_id FROM auth.user_site_roles
                     WHERE user_id = %s AND is_removed = false
                     ORDER BY site_id LIMIT 1
                    """,
                    (user_id,),
                )
                site_row = cur.fetchone()
                bind_site_id = site_row[0] if site_row else None

                if bind_site_id is not None:
                    # 换角色时先软删除旧绑定
                    if role_changed:
                        cur.execute(
                            """
                            UPDATE auth.user_assistant_binding
                               SET is_removed = true
                             WHERE user_id = %s AND site_id = %s AND is_removed = false
                            """,
                            (user_id, bind_site_id),
                        )

                    # 有绑定目标时写入新绑定
                    if new_assistant_id is not None or new_staff_id is not None:
                        binding_type = "assistant" if new_assistant_id is not None else "staff"
                        # 尝试更新现有活跃记录
                        cur.execute(
                            """
                            UPDATE auth.user_assistant_binding
                               SET assistant_id = %s, staff_id = %s, binding_type = %s
                             WHERE id = (
                                SELECT id FROM auth.user_assistant_binding
                                 WHERE user_id = %s AND site_id = %s AND is_removed = false
                                 ORDER BY id DESC LIMIT 1
                             )
                            """,
                            (new_assistant_id, new_staff_id, binding_type, user_id, bind_site_id),
                        )
                        if cur.rowcount == 0:
                            # 无活跃记录，插入新行
                            cur.execute(
                                """
                                INSERT INTO auth.user_assistant_binding
                                    (user_id, site_id, assistant_id, staff_id, binding_type)
                                VALUES (%s, %s, %s, %s, %s)
                                """,
                                (user_id, bind_site_id, new_assistant_id, new_staff_id, binding_type),
                            )
                    elif not role_changed:
                        # 解绑场景（选了"无"但角色没变）：软删除现有绑定
                        cur.execute(
                            """
                            UPDATE auth.user_assistant_binding
                               SET is_removed = true
                             WHERE user_id = %s AND site_id = %s AND is_removed = false
                            """,
                            (user_id, bind_site_id),
                        )

        conn.commit()
    except HTTPException:
        conn.rollback()
        raise
    except Exception:
        conn.rollback()
        logger.error("编辑用户失败（user_id=%s）", user_id, exc_info=True)
        raise HTTPException(status_code=500, detail="编辑操作失败")
    finally:
        conn.close()

    return {"message": "更新成功"}




# ── DELETE /api/tenant/users/{id} ─────────────────────────


@router.delete("/users/{user_id}")
async def remove_user(
    user_id: int,
    admin: CurrentTenantAdmin = Depends(require_tenant_admin),
):
    """从当前租户管辖的店铺中移除用户（软删除）。

    CHANGE 2026-03-23 | 租户"移除"替代"禁用"
    CHANGE 2026-03-24 | 物理删除改软删除：标记 is_removed + removed_at，同步标记 user_assistant_binding
    - 标记 auth.user_site_roles 中该用户在管辖 site 下的记录为已移除
    - 标记 auth.user_assistant_binding 中对应记录为已移除
    - 若用户不再有任何活跃 site 绑定，将 auth.users.status 改为 'new'（可重新申请）
    - 不影响用户的微信身份（第一层），只解除店铺关系（第二层）
    """
    conn = get_connection()
    try:
        with conn.cursor() as cur:
            # 验证用户存在且在管辖范围内（只查未移除的）
            site_sql, site_params = site_filter_clause(admin=admin)
            cur.execute(
                f"""
                SELECT usr.site_id
                  FROM auth.user_site_roles usr
                 WHERE usr.user_id = %s AND usr.{site_sql}
                   AND usr.is_removed = false
                """,
                (user_id, *site_params),
            )
            rows = cur.fetchall()
            if not rows:
                raise HTTPException(status_code=404, detail="用户不存在或不在管辖范围内")

            managed_site_ids = [r[0] for r in rows]

            # CHANGE 2026-03-24 | 软删除：标记 is_removed 而非物理删除
            placeholders = ", ".join(["%s"] * len(managed_site_ids))
            cur.execute(
                f"""
                UPDATE auth.user_site_roles
                   SET is_removed = true, removed_at = now()
                 WHERE user_id = %s AND site_id IN ({placeholders})
                   AND is_removed = false
                """,
                (user_id, *managed_site_ids),
            )

            # 同步软删除 user_assistant_binding（旧代码漏删，此处补全）
            cur.execute(
                f"""
                UPDATE auth.user_assistant_binding
                   SET is_removed = true, removed_at = now()
                 WHERE user_id = %s AND site_id IN ({placeholders})
                   AND is_removed = false
                """,
                (user_id, *managed_site_ids),
            )

            # 检查用户是否还有其他活跃 site 绑定
            cur.execute(
                "SELECT COUNT(*) FROM auth.user_site_roles WHERE user_id = %s AND is_removed = false",
                (user_id,),
            )
            remaining = cur.fetchone()[0]

            if remaining == 0:
                # 无任何店铺绑定，重置为 new 状态（可重新申请）
                cur.execute(
                    "UPDATE auth.users SET status = 'new', updated_at = now() WHERE id = %s",
                    (user_id,),
                )

        conn.commit()
    except HTTPException:
        conn.rollback()
        raise
    except Exception:
        conn.rollback()
        logger.error("移除用户失败（user_id=%s）", user_id, exc_info=True)
        raise HTTPException(status_code=500, detail="移除操作失败")
    finally:
        conn.close()

    return {"message": "用户已从店铺移除"}


# ── PUT /api/tenant/users/{id}/binding ────────────────────


@router.put("/users/{user_id}/binding")
async def update_binding(
    user_id: int,
    body: UserBindingRequest,
    admin: CurrentTenantAdmin = Depends(require_tenant_admin),
):
    """更新 user_assistant_binding。"""
    conn = get_connection()
    try:
        with conn.cursor() as cur:
            # 验证用户在管辖范围内
            site_sql, site_params = site_filter_clause(admin=admin)
            cur.execute(
                f"""
                SELECT 1 FROM auth.user_site_roles
                 WHERE user_id = %s AND {site_sql}
                   AND is_removed = false
                 LIMIT 1
                """,
                (user_id, *site_params),
            )
            if cur.fetchone() is None:
                raise HTTPException(status_code=404, detail="用户不存在")

            # CHANGE 2026-03-24 | 修复：旧代码 ON CONFLICT (user_id) 无效（表无唯一约束），
            # 导致每次"更新"实际插入新行，旧绑定残留。改为先更新最新记录，无记录则插入。
            # 同时从 user_site_roles 获取 site_id，补全 binding_type。
            cur.execute(
                """
                SELECT usr.site_id
                  FROM auth.user_site_roles usr
                 WHERE usr.user_id = %s
                   AND usr.is_removed = false
                 ORDER BY usr.site_id
                 LIMIT 1
                """,
                (user_id,),
            )
            site_row = cur.fetchone()
            if site_row is None:
                raise HTTPException(status_code=400, detail="用户无门店绑定")
            bind_site_id = site_row[0]

            # 确定 binding_type
            binding_type = "assistant" if body.assistant_id is not None else "staff"

            # 尝试更新最新一条绑定记录
            cur.execute(
                """
                UPDATE auth.user_assistant_binding
                   SET assistant_id = %s,
                       staff_id = %s,
                       binding_type = %s
                 WHERE id = (
                    SELECT id FROM auth.user_assistant_binding
                     WHERE user_id = %s AND site_id = %s
                       AND is_removed = false
                     ORDER BY id DESC
                     LIMIT 1
                 )
                """,
                (body.assistant_id, body.staff_id, binding_type, user_id, bind_site_id),
            )
            if cur.rowcount == 0:
                # 无现有记录，插入新行
                cur.execute(
                    """
                    INSERT INTO auth.user_assistant_binding
                        (user_id, site_id, assistant_id, staff_id, binding_type)
                    VALUES (%s, %s, %s, %s, %s)
                    """,
                    (user_id, bind_site_id, body.assistant_id, body.staff_id, binding_type),
                )

        conn.commit()
    except HTTPException:
        conn.rollback()
        raise
    except Exception:
        conn.rollback()
        logger.error("更新绑定失败（user_id=%s）", user_id, exc_info=True)
        raise HTTPException(status_code=500, detail="绑定更新失败")
    finally:
        conn.close()

    return {"message": "绑定更新成功"}
