"""バンドル（データセットの 1 つの版を manifest とファイルのコピーにまとめたもの）の読み込み・検証.

バンドルの形（SPEC_03「バンドル」）::

    <名前>/v<版>/
    ├── dataset.json          # 名前・版・作成日時・hub_commit・件数・manifest.csv の SHA-256
    ├── manifest.csv          # 1 行 1 ファイル
    ├── SHA256SUMS            # files/ の照合用（sha256sum -c で検証できる形式）
    └── files/<sha256>.<ext>  # ストアからのコピー

このモジュールは目録の無い計算サーバで使うため，`catalog`・`store`・`config` を import しない
（標準ライブラリ・pydantic・`tiasdata.manifest` だけを使う．SPEC_01「依存の向き」）．
書き出し（目録・ストアを使う側）は `tiasdata.export` にあり，ここの形の組み立て
（`build_dataset_json`・`build_sha256sums`）を共有する．

例外メッセージにはバンドルのパスを出さず，データセット名・版・件数・SHA-256 で示す
（SPEC_03「出力の規則」．計算サーバの出力も Claude が読みうるため，データの場所を出さない）．
"""

import csv
import io
import json
import os
import re
from collections.abc import Iterable, Sequence
from pathlib import Path

from pydantic import BaseModel, ConfigDict, Field, ValidationError

from tiasdata.manifest import (
    BASE_COLUMNS,
    DATASET_NAME_PATTERN,
    FILES_DIRNAME,
    MANIFEST_ENCODING,
    bundle_file_path,
    manifest_sha256,
    sha256_file,
    validation_error_details,
)

BUNDLES_ENV_VAR = "TIASDATA_BUNDLES"
DATASET_JSON = "dataset.json"
MANIFEST_CSV = "manifest.csv"
SHA256SUMS = "SHA256SUMS"
# dataset.json の形の版（形を変えたら上げ，読み込み側が知らない形を黙って読まないようにする）
BUNDLE_FORMAT = 1

_SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
_FILE_PATH_PATTERN = re.compile(rf"^{FILES_DIRNAME}/([0-9a-f]{{64}})\.([a-z0-9]+)$")
_ANNOTATION_COLUMN_PATTERN = re.compile(r"^(\w+)\.(\w+)$", re.ASCII)
# `sha256sum` の出力の形（`<sha256>  <パス>`．2 つ目の空白の代わりに `*` は二進モードの印）
_SHA256SUMS_LINE_PATTERN = re.compile(r"^([0-9a-f]{64}) [ *](.+)$")
# エラーメッセージに出す例の最大件数と，SHA-256 の桁数（位置の特定には先頭で足りる）
_MAX_EXAMPLES = 5
_SHORT_SHA256 = 12


class BundleInfo(BaseModel):
    """`dataset.json` の内容．

    Attributes:
        format: バンドルの形の版．
        name: データセット名．
        version: 版．
        created_at: 版を作った日時（UTC の ISO 8601）．書き出した日時ではない．
        hub_commit: 版を作ったときの本リポジトリのコミット．
        files: ファイルの数（manifest.csv の行数）．
        manifest_sha256: manifest.csv の SHA-256．
    """

    # 後で項目を足しても古い読み込み側が読めるよう，未知のキーは無視する
    model_config = ConfigDict(frozen=True, extra="ignore")

    format: int = Field(strict=True)
    name: str = Field(pattern=DATASET_NAME_PATTERN.pattern)
    version: int = Field(ge=1, strict=True)
    created_at: str
    hub_commit: str
    files: int = Field(ge=0, strict=True)
    manifest_sha256: str = Field(pattern=_SHA256_PATTERN.pattern)


class BundleRow(BaseModel):
    """manifest.csv の 1 行（バンドルの 1 ファイル）．

    Attributes:
        path: バンドルのルートからの相対パス（`files/<sha256>.<ext>`．区切りは `/`）．
            絶対パスは `Bundle.file_path(row)` で得る．
        sha256: ファイルの SHA-256．
        kind: ファイルの種類．
        subject_id: 研究用の症例 ID（分割の単位にする）．
        capture_id: 撮影 ID．
        annotations: 所見の値（所見の種類 → 項目 → 値）．manifest の `<所見>.<項目>` の列を
            まとめたもの．値は manifest の文字列のまま（文字列以外の値は JSON の表記）で，
            空の欄は None．
    """

    model_config = ConfigDict(frozen=True)

    path: str
    sha256: str
    kind: str
    subject_id: str
    capture_id: str
    annotations: dict[str, dict[str, str | None]]


class Bundle:
    """開いたバンドル（`open_bundle`・`load` で作る）．

    開いた時点で dataset.json と manifest.csv の SHA-256 の一致を確かめてある．
    ファイルの中身の照合は時間がかかるため `verify()` で別に行う．
    """

    def __init__(self, root: Path, info: BundleInfo, rows: Sequence[BundleRow]) -> None:
        """`open_bundle` から呼ぶ（使う側は直接作らない）．"""
        self._root = root
        self._info = info
        self._rows = tuple(rows)

    def __repr__(self) -> str:
        # パスを出さない（ログに残っても場所が分からないように）
        return f"Bundle(name={self.name!r}, version={self.version}, files={len(self._rows)})"

    @property
    def root(self) -> Path:
        """バンドルのフォルダ（絶対パス）．"""
        return self._root

    @property
    def info(self) -> BundleInfo:
        """dataset.json の内容．"""
        return self._info

    @property
    def name(self) -> str:
        """データセット名．"""
        return self._info.name

    @property
    def version(self) -> int:
        """版．"""
        return self._info.version

    @property
    def manifest_sha256(self) -> str:
        """manifest.csv の SHA-256．"""
        return self._info.manifest_sha256

    @property
    def rows(self) -> tuple[BundleRow, ...]:
        """manifest.csv の行（`subject_id`・`capture_id`・`sha256` の順）．"""
        return self._rows

    @property
    def fingerprint(self) -> dict[str, str | int]:
        """バンドルの指紋（`{"name", "version", "manifest_sha256"}`）．実験記録に残す．"""
        return {
            "name": self._info.name,
            "version": self._info.version,
            "manifest_sha256": self._info.manifest_sha256,
        }

    def file_path(self, row: BundleRow) -> Path:
        """行のファイルの絶対パス（画像を開くため）．

        Args:
            row: このバンドルの行．

        Returns:
            `<バンドルのフォルダ>/files/<sha256>.<ext>`．
        """
        return self._root / row.path

    def verify(self) -> None:
        """全ファイルの SHA-256 を照合する（時間がかかる）．

        manifest.csv の SHA-256（dataset.json と一致するか），SHA256SUMS と manifest の一致，
        files/ の各ファイルの有無・中身，files/ に manifest に無いファイルが無いかを確かめる．

        Raises:
            ValueError: manifest.csv が変わった，SHA256SUMS が無い・形が誤っている・manifest と
                食い違う，ファイルの欠け・中身の改変，または manifest に無いファイルがある．
        """
        label = _label(self._info)
        data = _read_bytes(self._root / MANIFEST_CSV, label, MANIFEST_CSV)
        if manifest_sha256(data) != self._info.manifest_sha256:
            raise ValueError(f"{label}: manifest.csv の SHA-256 が dataset.json と一致しない")

        sums = parse_sha256sums(_read_bytes(self._root / SHA256SUMS, label, SHA256SUMS), label)
        expected = {(row.sha256, row.path) for row in self._rows}
        if set(sums) != expected or len(sums) != len(expected):
            raise ValueError(f"{label}: SHA256SUMS の行が manifest.csv と一致しない")

        missing: list[str] = []
        mismatched: list[str] = []
        for row in self._rows:
            path = self.file_path(row)
            if not path.is_file():
                missing.append(row.sha256[:_SHORT_SHA256])
            elif sha256_file(path) != row.sha256:
                mismatched.append(row.sha256[:_SHORT_SHA256])
        unexpected = _count_unexpected_files(self._root, {row.path for row in self._rows})

        if missing or mismatched or unexpected:
            details = [
                f"欠け {len(missing)} 件" + _examples(missing),
                f"中身の不一致 {len(mismatched)} 件" + _examples(mismatched),
                f"manifest に無いファイル {unexpected} 件",
            ]
            raise ValueError(
                f"{label}: ファイルの照合に失敗した（{len(self._rows)} 件中．{'・'.join(details)}）"
            )


# ---------------------------------------------------------------------------
# 形の組み立て（書き出し側と共有する純関数）
# ---------------------------------------------------------------------------
def build_dataset_json(info: BundleInfo) -> bytes:
    """dataset.json のバイト列を作る（UTF-8・改行 LF・末尾に改行）．

    書き出した日時は入れない（同じ版から同じバイト列になるようにするため）．

    Args:
        info: dataset.json の内容．

    Returns:
        dataset.json の中身．
    """
    text = json.dumps(info.model_dump(), ensure_ascii=False, indent=2) + "\n"
    return text.encode("utf-8")


def build_sha256sums(files: Iterable[tuple[str, str]]) -> bytes:
    """SHA256SUMS のバイト列を作る（`sha256sum -c` で検証できる形．改行 LF）．

    各行は `<sha256>  <バンドルのルートからの相対パス>`（間は空白 2 つ）で，パスの順に並べる．

    Args:
        files: （SHA-256, 相対パス）．

    Returns:
        SHA256SUMS の中身．
    """
    lines = [f"{sha256}  {path}\n" for sha256, path in sorted(files, key=lambda f: f[1])]
    return "".join(lines).encode("utf-8")


def parse_sha256sums(data: bytes, label: str = "バンドル") -> list[tuple[str, str]]:
    """SHA256SUMS を読む．

    Args:
        data: SHA256SUMS の中身．
        label: エラーメッセージで対象を示す名前．

    Returns:
        （SHA-256, 相対パス）の並び（ファイルの順）．

    Raises:
        ValueError: UTF-8 でない，または形が誤っている行がある．
    """
    try:
        text = data.decode("utf-8")
    except UnicodeDecodeError as e:
        raise ValueError(f"{label}: SHA256SUMS が UTF-8 でない") from e
    entries: list[tuple[str, str]] = []
    for number, line in enumerate(text.split("\n"), start=1):
        if not line:
            continue
        match = _SHA256SUMS_LINE_PATTERN.match(line)
        if match is None:
            raise ValueError(f"{label}: SHA256SUMS の {number} 行目の形が誤っている")
        entries.append((match.group(1), match.group(2)))
    return entries


def check_dataset_name(name: str) -> None:
    """データセット名がパスに使える形かを確かめる．

    Args:
        name: データセット名．

    Raises:
        ValueError: 英小文字で始まり英小文字・数字・`_` だけの名前でない．
    """
    if not isinstance(name, str) or not DATASET_NAME_PATTERN.match(name):
        raise ValueError(
            f"データセット名 {name!r} は英小文字で始まり，英小文字・数字・_ だけにする"
        )


def check_version(version: int) -> None:
    """版が 1 以上の整数かを確かめる．

    Args:
        version: 版．

    Raises:
        ValueError: 整数でない（真偽値を含む），または 1 未満．
    """
    if isinstance(version, bool) or not isinstance(version, int) or version < 1:
        raise ValueError(f"版は 1 以上の整数で指定する（指定: {version!r}）")


def bundle_relative_dir(name: str, version: int) -> Path:
    """バンドルの置き場所からの相対パス（`<名前>/v<版>`）．

    Args:
        name: データセット名．
        version: 版．

    Returns:
        `<名前>/v<版>`．

    Raises:
        ValueError: 名前・版の形が誤っている．
    """
    check_dataset_name(name)
    check_version(version)
    return Path(name) / f"v{version}"


# ---------------------------------------------------------------------------
# 読み込み
# ---------------------------------------------------------------------------
def default_bundles_dir() -> Path:
    """バンドルの置き場所を返す．

    環境変数 `TIASDATA_BUNDLES` があればそのパス，無ければ `~/data/tiasdata`
    （SPEC_01「使う側からの利用」）．

    Returns:
        バンドルの置き場所（存在するかは確かめない）．
    """
    env = os.environ.get(BUNDLES_ENV_VAR)
    if env:
        return Path(env).expanduser()
    return Path.home() / "data" / "tiasdata"


def load(name: str, *, version: int) -> Bundle:
    """データセットの版を指定してバンドルを開く（`TIASDATA_BUNDLES/<名前>/v<版>/`）．

    版の省略は許さない（暗黙に最新を使うと再現性が崩れるため．SPEC_01）．

    Args:
        name: データセット名（例: `tongue_color`）．
        version: 版（1 以上．キーワードで指定する）．

    Returns:
        開いたバンドル．

    Raises:
        ValueError: 名前・版の形が誤っている，バンドルが見つからない，形が誤っている，
            manifest の SHA-256 が一致しない，または dataset.json の名前・版が指定と違う．
    """
    root = default_bundles_dir() / bundle_relative_dir(name, version)
    if not root.is_dir():
        raise ValueError(
            f"データセット '{name}' の版 {version} のバンドルが無い"
            f"（環境変数 {BUNDLES_ENV_VAR} の場所（既定 ~/data/tiasdata）の {name}/v{version}/）"
        )
    bundle = open_bundle(root)
    if bundle.name != name or bundle.version != version:
        raise ValueError(
            f"{name}/v{version}/ のバンドルの dataset.json がデータセット '{bundle.name}' の"
            f"版 {bundle.version} になっている"
        )
    return bundle


def open_bundle(path: str | os.PathLike[str]) -> Bundle:
    """パスを指定してバンドルを開く．

    dataset.json と manifest.csv を読み，manifest.csv の SHA-256 が dataset.json と
    一致することを確かめる（一致しなければ停止）．ファイルの中身は照合しない（`verify()`）．

    Args:
        path: バンドルのフォルダ（`<名前>/v<版>/`）．

    Returns:
        開いたバンドル．

    Raises:
        ValueError: フォルダ・dataset.json・manifest.csv が無い，形が誤っている，
            または manifest.csv の SHA-256 が dataset.json と一致しない．
    """
    root = Path(path).expanduser().resolve()
    if not root.is_dir():
        raise ValueError("バンドルのフォルダが無い")
    info = _parse_info(_read_bytes(root / DATASET_JSON, "バンドル", DATASET_JSON))
    label = _label(info)
    data = _read_bytes(root / MANIFEST_CSV, label, MANIFEST_CSV)
    if manifest_sha256(data) != info.manifest_sha256:
        raise ValueError(
            f"{label}: manifest.csv の SHA-256 が dataset.json と一致しない"
            "（manifest.csv が書き換えられたか，別の版のもの）"
        )
    rows = parse_manifest(data, label)
    if len(rows) != info.files:
        raise ValueError(
            f"{label}: manifest.csv の行数 {len(rows)} が dataset.json の件数 {info.files} と"
            "一致しない"
        )
    return Bundle(root, info, rows)


def parse_manifest(data: bytes, label: str = "バンドル") -> list[BundleRow]:
    """manifest.csv を読んで行にする．

    Args:
        data: manifest.csv の中身（UTF-8・BOM なし）．
        label: エラーメッセージで対象を示す名前．

    Returns:
        manifest の行（ファイルの順）．

    Raises:
        ValueError: UTF-8 でない，見出しが誤っている，列の数が合わない行・パスの形が誤っている
            行・SHA-256 の重複がある．
    """
    try:
        text = data.decode(MANIFEST_ENCODING)
    except UnicodeDecodeError as e:
        raise ValueError(f"{label}: manifest.csv が UTF-8 でない") from e
    reader = csv.reader(io.StringIO(text, newline=""))
    header = next(reader, None)
    if header is None or tuple(header[: len(BASE_COLUMNS)]) != BASE_COLUMNS:
        raise ValueError(
            f"{label}: manifest.csv の見出しが {', '.join(BASE_COLUMNS)} で始まっていない"
        )
    annotation_columns = _parse_annotation_columns(header[len(BASE_COLUMNS) :], label)

    rows: list[BundleRow] = []
    seen: set[str] = set()
    for number, record in enumerate(reader, start=2):
        if len(record) != len(header):
            raise ValueError(f"{label}: manifest.csv の {number} 行目の列の数が見出しと違う")
        path, sha256, kind, subject_id, capture_id = record[: len(BASE_COLUMNS)]
        match = _FILE_PATH_PATTERN.match(path)
        if (
            not _SHA256_PATTERN.match(sha256)
            or match is None
            or path != bundle_file_path(sha256, match.group(2))
        ):
            raise ValueError(
                f"{label}: manifest.csv の {number} 行目の path・sha256 の形が誤っている"
                f"（path は {FILES_DIRNAME}/<sha256>.<拡張子>）"
            )
        if sha256 in seen:
            raise ValueError(f"{label}: manifest.csv に SHA-256 {sha256} が 2 回以上ある")
        seen.add(sha256)
        annotations: dict[str, dict[str, str | None]] = {}
        for (kind_name, field), cell in zip(
            annotation_columns, record[len(BASE_COLUMNS) :], strict=True
        ):
            annotations.setdefault(kind_name, {})[field] = cell if cell != "" else None
        rows.append(
            BundleRow(
                path=path,
                sha256=sha256,
                kind=kind,
                subject_id=subject_id,
                capture_id=capture_id,
                annotations=annotations,
            )
        )
    return rows


def _parse_annotation_columns(columns: Sequence[str], label: str) -> list[tuple[str, str]]:
    parsed: list[tuple[str, str]] = []
    for column in columns:
        match = _ANNOTATION_COLUMN_PATTERN.match(column)
        if match is None:
            raise ValueError(f"{label}: manifest.csv の列 '{column}' が <所見>.<項目> の形でない")
        parsed.append((match.group(1), match.group(2)))
    if len(set(parsed)) != len(parsed):
        raise ValueError(f"{label}: manifest.csv に同じ所見の列が 2 回以上ある")
    return parsed


def _parse_info(data: bytes) -> BundleInfo:
    try:
        raw = json.loads(data.decode("utf-8"))
    except (UnicodeDecodeError, json.JSONDecodeError) as e:
        raise ValueError(f"バンドルの {DATASET_JSON} を JSON として読めない: {e}") from e
    if not isinstance(raw, dict):
        raise ValueError(f"バンドルの {DATASET_JSON} が JSON のオブジェクトでない")
    try:
        info = BundleInfo.model_validate(raw)
    except ValidationError as e:
        raise ValueError(
            f"バンドルの {DATASET_JSON} の形が誤っている: {validation_error_details(e)}"
        ) from e
    if info.format != BUNDLE_FORMAT:
        raise ValueError(
            f"バンドルの形の版 {info.format} は読めない（読めるのは {BUNDLE_FORMAT}）．"
            "tiasdata を更新する"
        )
    return info


def _read_bytes(path: Path, label: str, filename: str) -> bytes:
    # Windows ではフォルダを読むと PermissionError になるため，先にファイルかを確かめる
    if not path.is_file():
        raise ValueError(f"{label}: {filename} が無い，またはファイルでない")
    return path.read_bytes()


def _count_unexpected_files(root: Path, expected_paths: set[str]) -> int:
    files_dir = root / FILES_DIRNAME
    if not files_dir.is_dir():
        return 0
    return sum(
        1 for path in files_dir.iterdir() if f"{FILES_DIRNAME}/{path.name}" not in expected_paths
    )


def _label(info: BundleInfo) -> str:
    return f"バンドル {info.name} v{info.version}"


def _examples(items: Sequence[str]) -> str:
    if not items:
        return ""
    shown = ", ".join(items[:_MAX_EXAMPLES])
    return f"（例: {shown}{' …' if len(items) > _MAX_EXAMPLES else ''}）"
