from __future__ import annotations

import hashlib
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any


class ManifestConflictError(Exception):
    """乐观锁冲突：远端 manifest 版本已变更。"""

    def __init__(self, expected: int, actual: int) -> None:
        super().__init__(f"manifest version conflict: expected {expected}, got {actual}")
        self.expected = expected
        self.actual = actual


@dataclass
class FileEntry:
    sha256: str
    size: int
    tier: str = "hot"
    updated_at: str = ""

    def to_dict(self) -> dict[str, Any]:
        return {
            "sha256": self.sha256,
            "size": self.size,
            "tier": self.tier,
            "updated_at": self.updated_at,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> FileEntry:
        return cls(
            sha256=data["sha256"],
            size=int(data["size"]),
            tier=data.get("tier", "hot"),
            updated_at=data.get("updated_at", ""),
        )


@dataclass
class Manifest:
    version: int = 0
    updated_at: str = ""
    files: dict[str, FileEntry] = field(default_factory=dict)
    nodes: dict[str, dict[str, str]] = field(default_factory=dict)

    def to_dict(self) -> dict[str, Any]:
        return {
            "version": self.version,
            "updated_at": self.updated_at,
            "files": {k: v.to_dict() for k, v in self.files.items()},
            "nodes": self.nodes,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> Manifest:
        files = {
            k: FileEntry.from_dict(v) for k, v in (data.get("files") or {}).items()
        }
        return cls(
            version=int(data.get("version", 0)),
            updated_at=data.get("updated_at", ""),
            files=files,
            nodes=dict(data.get("nodes") or {}),
        )


def sha256_file(path: Path) -> str:
    h = hashlib.sha256()
    with path.open("rb") as f:
        for chunk in iter(lambda: f.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()


def utc_now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


class ManifestManager:
    """manifest.json 读写与乐观锁。"""

    def __init__(self, path: Path) -> None:
        self._path = path
        self._path.parent.mkdir(parents=True, exist_ok=True)

    @property
    def path(self) -> Path:
        return self._path

    def read(self) -> Manifest:
        if not self._path.is_file():
            return Manifest(version=0, updated_at=utc_now_iso())
        data = json.loads(self._path.read_text(encoding="utf-8"))
        return Manifest.from_dict(data)

    def write(self, manifest: Manifest, *, expected_version: int | None = None) -> Manifest:
        current = self.read()
        if expected_version is not None and current.version != expected_version:
            raise ManifestConflictError(expected_version, current.version)

        manifest.updated_at = utc_now_iso()
        tmp = self._path.with_suffix(".json.tmp")
        tmp.write_text(
            json.dumps(manifest.to_dict(), ensure_ascii=False, indent=2) + "\n",
            encoding="utf-8",
        )
        tmp.replace(self._path)
        return manifest

    def bump_version(self, *, expected_version: int) -> Manifest:
        manifest = self.read()
        if manifest.version != expected_version:
            raise ManifestConflictError(expected_version, manifest.version)
        manifest.version = expected_version + 1
        return self.write(manifest, expected_version=expected_version)

    def update_file_entry(self, rel_path: str, file_path: Path, *, tier: str = "hot") -> Manifest:
        """更新单文件条目并 version++（乐观锁）。"""
        manifest = self.read()
        expected = manifest.version
        if file_path.is_file():
            manifest.files[rel_path] = FileEntry(
                sha256=sha256_file(file_path),
                size=file_path.stat().st_size,
                tier=tier,
                updated_at=utc_now_iso(),
            )
        elif rel_path in manifest.files:
            del manifest.files[rel_path]
        manifest.version = expected + 1
        return self.write(manifest, expected_version=expected)
