# Source: https://github.com/CN-Grace/QinglongScripts/blob/main/Sync_Password.py
# Raw: https://raw.githubusercontent.com/CN-Grace/QinglongScripts/main/Sync_Password.py
# Repo: CN-Grace/QinglongScripts
# Path: Sync_Password.py
# UploadedAt: 2026-05-23T15:48:24Z
# SHA256: e63fb7c92c4a77f2dc4660a1ce3f0401a8173132dc4b8d36a99c7d383f91974e
# Category: web版/账密
# Evidence: web/H5关键词 + username/password/login

#!/usr/bin/env python3
"""
cron: 0 0 * * *
new Env("Bitwarden备份")
Bitwarden 自动备份脚本（带哈希比较，避免重复同步）
- 自动登录 Bitwarden 服务器并获取所有密码数据
- 将密码数据保存到本地 JSON 文件
- 比较今天与昨天的备份文件哈希，若无变化则跳过 WebDAV 同步
- 将备份文件同步到 WebDAV 服务器（仅当有更新时）
"""

import os
import json
import hashlib
import requests
import datetime
from requests.auth import HTTPBasicAuth
from typing import Dict, Any, Tuple

from utils import log_info, log_success, log_warning, log_error, beijing_now, beijing_time_str
from notifier import send as notify_send, send_file as notify_send_file

# ==================== 用户配置 ====================
BITWARDEN_SERVER = os.environ.get("BITWARDEN_SERVER", "")
BITWARDEN_USERNAME = os.environ.get("BITWARDEN_USERNAME", "")
BITWARDEN_PASSWORD = os.environ.get("BITWARDEN_PASSWORD", "")
WEBDAV_SERVER = os.environ.get("WEBDAV_SERVER", "")
WEBDAV_USERNAME = os.environ.get("WEBDAV_USERNAME", "")
WEBDAV_PASSWORD = os.environ.get("WEBDAV_PASSWORD", "")
WEBDAV_REMOTE_DIR = os.environ.get("WEBDAV_REMOTE_DIR", "")
LOCAL_BACKUP_PATH = os.environ.get("SYNC_LOCAL_DIR", "Password/")
BACKUP_FILENAME_FORMAT = "%Y-%m-%d_bitwarden_backup.json"


def create_session() -> requests.Session:
    session = requests.Session()
    session.headers.update({"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "application/json, text/plain, */*"})
    return session


def bitwarden_login(session: requests.Session) -> Tuple[bool, str]:
    login_url = f"{BITWARDEN_SERVER.rstrip('/')}/identity/connect/token"
    login_data = {
        "scope": "api offline_access", "client_id": "web", "deviceType": "12",
        "deviceIdentifier": "65ff3d73-fd5f-4835-8a50-fa7f41581f48", "deviceName": "edge",
        "grant_type": "password", "username": BITWARDEN_USERNAME, "password": BITWARDEN_PASSWORD,
    }
    try:
        log_info(f"正在登录 Bitwarden: {BITWARDEN_SERVER}")
        resp = session.post(login_url, data=login_data, timeout=30)
        resp.raise_for_status()
        access_token = resp.json().get("access_token")
        if access_token:
            session.headers.update({"Authorization": f"Bearer {access_token}"})
            log_success("Bitwarden 登录成功")
            return True, "✅ Bitwarden 登录成功"
        log_error("响应中未找到 access_token")
        return False, "❌ Bitwarden 登录失败: 未找到 access_token"
    except Exception as e:
        log_error(f"Bitwarden 登录失败: {e}")
        return False, f"❌ Bitwarden 登录失败: {e}"


def get_bitwarden_data(session: requests.Session) -> Tuple[bool, Any, str]:
    sync_url = f"{BITWARDEN_SERVER.rstrip('/')}/api/sync"
    try:
        log_info("正在获取 Bitwarden 数据...")
        resp = session.get(sync_url, params={"excludeDomains": "true"}, timeout=30)
        resp.raise_for_status()
        data = resp.json()
        if data and isinstance(data, dict):
            item_count = len(data.get("ciphers", []))
            log_success(f"成功获取数据 ({item_count} 个密码项)")
            return True, data, f"✅ 成功获取数据 ({item_count} 个密码项)"
        return False, None, "⚠️ 数据为空或格式不正确"
    except Exception as e:
        log_error(f"获取数据失败: {e}")
        return False, None, f"❌ 获取数据失败: {e}"


def save_backup_locally(data: Dict[str, Any]) -> Tuple[bool, str, str]:
    if not os.path.exists(LOCAL_BACKUP_PATH):
        os.makedirs(LOCAL_BACKUP_PATH, exist_ok=True)
    today = beijing_now()
    filename = today.strftime(BACKUP_FILENAME_FORMAT)
    filepath = os.path.join(LOCAL_BACKUP_PATH, filename)
    try:
        with open(filepath, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=4, ensure_ascii=False)
        file_size = os.path.getsize(filepath) / (1024 * 1024)
        log_success(f"本地备份: {filename} ({file_size:.2f} MB)")
        return True, filepath, f"✅ 本地备份成功: {filename} ({file_size:.2f} MB)"
    except Exception as e:
        log_error(f"本地备份失败: {e}")
        return False, "", f"❌ 本地备份失败: {e}"


def get_file_hash(filepath: str) -> str:
    hash_md5 = hashlib.md5()
    with open(filepath, "rb") as f:
        for chunk in iter(lambda: f.read(4096), b""):
            hash_md5.update(chunk)
    return hash_md5.hexdigest()


def sync_to_webdav(local_filepath: str) -> Tuple[bool, str]:
    if not os.path.exists(local_filepath):
        return False, "❌ WebDAV 同步失败: 本地文件不存在"
    filename = os.path.basename(local_filepath)
    remote_dir = WEBDAV_REMOTE_DIR.rstrip("/") if WEBDAV_REMOTE_DIR else "123Pan/Password"
    remote_path = f"{WEBDAV_SERVER.rstrip('/')}/{remote_dir}/{filename}"
    try:
        with open(local_filepath, "rb") as f:
            file_data = f.read()
        log_info(f"正在上传到 WebDAV: {remote_path}")
        resp = requests.put(url=remote_path, data=file_data, auth=HTTPBasicAuth(WEBDAV_USERNAME, WEBDAV_PASSWORD), headers={"Content-Type": "application/octet-stream"}, timeout=60)
        if resp.status_code in (200, 201, 204):
            log_success("WebDAV 同步成功")
            return True, f"✅ WebDAV 同步成功: {filename}"
        return False, f"❌ WebDAV 同步失败: 状态码 {resp.status_code}"
    except Exception as e:
        log_error(f"WebDAV 同步失败: {e}")
        return False, f"❌ WebDAV 同步失败: {e}"


def build_report_content(log_messages: list) -> str:
    lines = [
        "🔐 Bitwarden 备份报告", "",
        f"📅 备份时间: {beijing_time_str()}",
        f"👤 账户: {BITWARDEN_USERNAME}", "",
        "─" * 18, "",
        "📋 备份步骤状态:",
    ]
    for i, msg in enumerate(log_messages, 1):
        lines.append(f"  {i}. {msg}")

    success_count = sum(1 for m in log_messages if m.startswith("✅"))
    info_count = sum(1 for m in log_messages if m.startswith("ℹ️") or m.startswith("⚠️"))
    skip_count = sum(1 for m in log_messages if m.startswith("⏭️"))

    lines.extend(["", "─" * 18, f"📊 统计: 成功 {success_count} | 信息 {info_count} | 跳过 {skip_count} | 总计 {len(log_messages)}", "", f"🕒 执行时间: {beijing_time_str()}"])
    return "\n".join(lines)


def perform_backup() -> Tuple[bool, list, str, bool]:
    log_messages = []
    backup_filepath = None
    has_update = True

    session = create_session()
    login_success, login_msg = bitwarden_login(session)
    log_messages.append(login_msg)
    if not login_success:
        return False, log_messages, None, has_update

    data_success, bitwarden_data, data_msg = get_bitwarden_data(session)
    log_messages.append(data_msg)
    if not data_success or bitwarden_data is None:
        return False, log_messages, None, has_update

    save_success, filepath, save_msg = save_backup_locally(bitwarden_data)
    log_messages.append(save_msg)
    backup_filepath = filepath if save_success else None

    if save_success and backup_filepath:
        today = beijing_now()
        yesterday = today - datetime.timedelta(days=1)
        yesterday_filename = yesterday.strftime(BACKUP_FILENAME_FORMAT)
        yesterday_filepath = os.path.join(LOCAL_BACKUP_PATH, yesterday_filename)
        if os.path.exists(yesterday_filepath):
            if get_file_hash(backup_filepath) == get_file_hash(yesterday_filepath):
                has_update = False
                log_info("备份文件与昨天相同，无更新")
                log_messages.append("ℹ️ 备份文件与昨日一致，无新内容")
            else:
                log_info("备份文件有更新")
        else:
            log_info("昨日备份不存在，视为有更新")
        if has_update:
            sync_success, sync_msg = sync_to_webdav(backup_filepath)
            log_messages.append(sync_msg)
        else:
            log_messages.append("⏭️ WebDAV 同步跳过: 文件无变化")
    else:
        log_messages.append("⏭️ WebDAV 同步跳过: 本地备份失败")

    all_success = all(msg.startswith("✅") or msg.startswith("⏭️") or msg.startswith("ℹ️") for msg in log_messages)
    return all_success, log_messages, backup_filepath, has_update


def main():
    log_info("=" * 50)
    log_info("Bitwarden 自动备份脚本开始执行")
    log_info("=" * 50)

    start_time = beijing_now()
    backup_success, log_messages, backup_filepath, has_update = perform_backup()

    title = f"Bitwarden 备份报告"
    content = build_report_content(log_messages)

    if has_update and backup_filepath and os.path.exists(backup_filepath):
        notify_send_file(title, content, backup_filepath)
    else:
        notify_send(title, content)

    print(f"\n{'=' * 60}\nBitwarden 备份完成报告:\n{'=' * 60}")
    for i, msg in enumerate(log_messages, 1):
        print(f"{i}. {msg}")
    print(f"\n执行时间: {(beijing_now() - start_time).total_seconds():.2f}秒")
    print("=" * 60)

    return 0 if backup_success else 1


if __name__ == "__main__":
    import sys
    try:
        exit_code = main()
    except KeyboardInterrupt:
        log_info("用户中断执行")
        exit_code = 130
    except Exception as e:
        log_error(f"脚本异常: {e}")
        exit_code = 1
    log_info(f"脚本执行结束，退出代码: {exit_code}")
    sys.exit(exit_code)
