import gzip
import json
import os
import re
import shutil
import subprocess
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path

from sqlalchemy.engine import make_url

from app.core.config import settings
from app.core.paths import data_path
from app.db.session import engine

BACKUP_PREFIX = "certificate_system"
BACKUP_TIMEOUT_SECONDS = 600
BACKUP_NAME_PATTERN = re.compile(
    r"^certificate_system_\d{8}_\d{6}(?:_[a-z0-9_-]+)?\.(?:sql\.gz|sqlite3)$"
)


class BackupError(RuntimeError):
    pass


@dataclass(frozen=True)
class DatabaseBackup:
    filename: str
    database_type: str
    created_at: datetime
    size_bytes: int
    size_label: str
    reason: str | None = None
    note: str | None = None

    def to_dict(self) -> dict[str, object]:
        return {
            "filename": self.filename,
            "database_type": self.database_type,
            "created_at": self.created_at,
            "size_bytes": self.size_bytes,
            "size_label": self.size_label,
            "reason": self.reason,
            "note": self.note,
        }


def list_database_backups() -> list[dict[str, object]]:
    backups = [_backup_info(path).to_dict() for path in _backup_dir().iterdir() if _is_backup_file(path)]
    return sorted(backups, key=lambda item: item["created_at"], reverse=True)


def create_database_backup(reason: str | None = "manual") -> dict[str, object]:
    kind = _database_kind()
    created_at = datetime.now(timezone.utc)
    target = _backup_target(kind, reason, created_at)

    if kind == "sqlite":
        _backup_sqlite(target)
    elif kind == "mysql":
        _backup_mysql(target)
    else:
        raise BackupError(f"当前数据库类型暂不支持后台备份：{kind}")

    info = _backup_info(target, created_at=created_at, reason=reason, database_type=kind)
    _write_metadata(target, info)
    return info.to_dict()


def restore_database_backup(filename: str, confirmation: str) -> dict[str, object]:
    backup_path = _resolve_backup_path(filename)
    expected_confirmation = f"RESTORE {filename}"
    if confirmation.strip() != expected_confirmation:
        raise BackupError(f"确认文本不正确，请输入：{expected_confirmation}")

    restored_backup = _backup_info(backup_path)
    kind = _database_kind()
    if restored_backup.database_type != kind:
        raise BackupError(f"备份类型是 {restored_backup.database_type}，当前数据库类型是 {kind}，不能混用恢复")

    pre_restore_backup = create_database_backup(reason=f"pre_restore_{filename}")
    engine.dispose()
    if kind == "sqlite":
        _restore_sqlite(backup_path)
    elif kind == "mysql":
        _restore_mysql(backup_path)
    else:
        raise BackupError(f"当前数据库类型暂不支持后台回滚：{kind}")
    engine.dispose()

    return {
        "restored_backup": restored_backup.to_dict(),
        "pre_restore_backup": pre_restore_backup,
    }


def _backup_dir() -> Path:
    path = data_path("db-backups")
    path.mkdir(parents=True, exist_ok=True)
    return path


def _backup_target(kind: str, reason: str | None, created_at: datetime) -> Path:
    suffix = _reason_suffix(reason)
    extension = "sqlite3" if kind == "sqlite" else "sql.gz"
    base_name = f"{BACKUP_PREFIX}_{created_at:%Y%m%d_%H%M%S}{suffix}"
    target = _backup_dir() / f"{base_name}.{extension}"
    counter = 2
    while target.exists():
        target = _backup_dir() / f"{base_name}_{counter}.{extension}"
        counter += 1
    return target


def _database_kind() -> str:
    driver = make_url(settings.database_url).drivername
    if driver.startswith("sqlite"):
        return "sqlite"
    if driver.startswith("mysql"):
        return "mysql"
    return driver.split("+", 1)[0]


def _backup_sqlite(target: Path) -> None:
    source = _sqlite_database_path()
    if not source.exists():
        raise BackupError(f"SQLite 数据库文件不存在：{source}")
    shutil.copy2(source, target)


def _restore_sqlite(backup_path: Path) -> None:
    if backup_path.suffix != ".sqlite3":
        raise BackupError("SQLite 只能恢复 .sqlite3 备份")
    target = _sqlite_database_path()
    target.parent.mkdir(parents=True, exist_ok=True)
    shutil.copy2(backup_path, target)


def _sqlite_database_path() -> Path:
    url = make_url(settings.database_url)
    if not url.database:
        raise BackupError("SQLite DATABASE_URL 缺少数据库文件路径")
    return Path(url.database).expanduser().resolve()


def _backup_mysql(target: Path) -> None:
    cmd, env = _mysql_command("mysqldump")
    cmd.extend([
        "--single-transaction",
        "--quick",
        "--routines",
        "--triggers",
        "--events",
        "--default-character-set=utf8mb4",
        _mysql_database_name(),
    ])
    try:
        with gzip.open(target, "wb") as output:
            subprocess.run(
                cmd,
                stdout=output,
                stderr=subprocess.PIPE,
                env=env,
                check=True,
                timeout=BACKUP_TIMEOUT_SECONDS,
            )
    except FileNotFoundError as exc:
        raise BackupError("后端环境缺少 mysqldump 命令，请重新构建 backend 镜像") from exc
    except subprocess.CalledProcessError as exc:
        target.unlink(missing_ok=True)
        raise BackupError(_subprocess_error("数据库备份失败", exc)) from exc
    except subprocess.TimeoutExpired as exc:
        target.unlink(missing_ok=True)
        raise BackupError("数据库备份超时，请稍后重试或检查数据库连接") from exc


def _restore_mysql(backup_path: Path) -> None:
    if not backup_path.name.endswith(".sql.gz"):
        raise BackupError("MySQL 只能恢复 .sql.gz 备份")
    cmd, env = _mysql_command("mysql")
    cmd.append(_mysql_database_name())
    try:
        with gzip.open(backup_path, "rb") as dump:
            subprocess.run(
                cmd,
                stdin=dump,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                env=env,
                check=True,
                timeout=BACKUP_TIMEOUT_SECONDS,
            )
    except FileNotFoundError as exc:
        raise BackupError("后端环境缺少 mysql 命令，请重新构建 backend 镜像") from exc
    except subprocess.CalledProcessError as exc:
        raise BackupError(_subprocess_error("数据库回滚失败", exc)) from exc
    except subprocess.TimeoutExpired as exc:
        raise BackupError("数据库回滚超时，请检查数据库连接和备份文件大小") from exc


def _mysql_command(binary: str) -> tuple[list[str], dict[str, str]]:
    url = make_url(settings.database_url)
    command = [
        binary,
        "-h",
        url.host or "127.0.0.1",
        "-P",
        str(url.port or 3306),
    ]
    if url.username:
        command.extend(["-u", url.username])
    env = os.environ.copy()
    if url.password:
        env["MYSQL_PWD"] = url.password
    return command, env


def _mysql_database_name() -> str:
    database = make_url(settings.database_url).database
    if not database:
        raise BackupError("MySQL DATABASE_URL 缺少数据库名")
    return database


def _resolve_backup_path(filename: str) -> Path:
    if not BACKUP_NAME_PATTERN.match(filename):
        raise BackupError("备份文件名不合法")
    path = (_backup_dir() / filename).resolve()
    if path.parent != _backup_dir().resolve() or not path.exists() or not path.is_file():
        raise BackupError("备份文件不存在")
    return path


def _is_backup_file(path: Path) -> bool:
    return path.is_file() and BACKUP_NAME_PATTERN.match(path.name) is not None


def _backup_info(
    path: Path,
    created_at: datetime | None = None,
    reason: str | None = None,
    database_type: str | None = None,
) -> DatabaseBackup:
    metadata = _read_metadata(path)
    stat = path.stat()
    meta_created_at = _parse_datetime(metadata.get("created_at")) if metadata else None
    return DatabaseBackup(
        filename=path.name,
        database_type=database_type or str(metadata.get("database_type") or _type_from_extension(path)),
        created_at=created_at or meta_created_at or datetime.fromtimestamp(stat.st_mtime, timezone.utc),
        size_bytes=stat.st_size,
        size_label=_size_label(stat.st_size),
        reason=reason if reason is not None else metadata.get("reason"),
        note=metadata.get("note"),
    )


def _write_metadata(path: Path, info: DatabaseBackup) -> None:
    payload = info.to_dict()
    payload["created_at"] = info.created_at.isoformat()
    _metadata_path(path).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")


def _read_metadata(path: Path) -> dict[str, object]:
    metadata_path = _metadata_path(path)
    if not metadata_path.exists():
        return {}
    try:
        payload = json.loads(metadata_path.read_text(encoding="utf-8"))
    except Exception:
        return {}
    return payload if isinstance(payload, dict) else {}


def _metadata_path(path: Path) -> Path:
    return path.with_name(f"{path.name}.json")


def _type_from_extension(path: Path) -> str:
    if path.name.endswith(".sql.gz"):
        return "mysql"
    if path.suffix == ".sqlite3":
        return "sqlite"
    return "unknown"


def _parse_datetime(value: object) -> datetime | None:
    if not isinstance(value, str):
        return None
    try:
        parsed = datetime.fromisoformat(value)
    except ValueError:
        return None
    if parsed.tzinfo is None:
        return parsed.replace(tzinfo=timezone.utc)
    return parsed


def _size_label(size_bytes: int) -> str:
    value = float(size_bytes)
    for unit in ["B", "KB", "MB", "GB"]:
        if value < 1024 or unit == "GB":
            return f"{value:.1f} {unit}" if unit != "B" else f"{int(value)} B"
        value /= 1024
    return f"{size_bytes} B"


def _reason_suffix(reason: str | None) -> str:
    cleaned = re.sub(r"[^a-z0-9_-]+", "_", (reason or "manual").strip().lower()).strip("_")
    if not cleaned or cleaned == "manual":
        return ""
    return f"_{cleaned[:48]}"


def _subprocess_error(prefix: str, exc: subprocess.CalledProcessError) -> str:
    stderr = (exc.stderr or b"").decode("utf-8", errors="replace").strip()
    return f"{prefix}：{stderr or '命令执行失败'}"
