# Copyright (C) 2026 NPO NT
# SPDX-License-Identifier: AGPL-3.0-only

from __future__ import annotations

import csv
import hashlib
import json
import re
from dataclasses import dataclass, field
from pathlib import Path


REQUIRED_COLUMNS = {
    "document_id",
    "file_path",
    "sha256",
    "document_type",
    "language",
    "page_count",
    "source_kind",
    "quality_tier",
    "contains_handwriting",
    "contains_barcode",
    "split_group",
}

ALLOWED_CATEGORIES = {
    "person_name",
    "birth_date",
    "phone",
    "email",
    "snils",
    "passport",
    "insurance_policy",
    "medical_record_id",
    "address",
    "barcode",
    "signature",
    "face",
    "organization",
    "event_date",
    "indirect_identifier",
}

SUSPICIOUS_FILENAME = re.compile(
    r"(?:@|\+?7\d{9,}|\d{2}[._-]\d{2}[._-](?:19|20)\d{2}|паспорт|полис|снилс|patient|пациент)",
    re.IGNORECASE,
)


@dataclass(slots=True)
class DatasetValidation:
    passed: bool
    document_count: int
    annotation_count: int
    issues: list[str] = field(default_factory=list)
    warnings: list[str] = field(default_factory=list)

    def as_dict(self) -> dict[str, object]:
        return {
            "schema": "mika-deid-dataset-validation-v1",
            "passed": self.passed,
            "document_count": self.document_count,
            "annotation_count": self.annotation_count,
            "issues": self.issues,
            "warnings": self.warnings,
        }


def _sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as stream:
        for chunk in iter(lambda: stream.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def _safe_relative(root: Path, relative: str) -> Path | None:
    candidate = (root / relative).resolve()
    try:
        candidate.relative_to(root.resolve())
    except ValueError:
        return None
    return candidate


def validate_dataset(root_path: str | Path) -> DatasetValidation:
    root = Path(root_path).expanduser().resolve()
    issues: list[str] = []
    warnings: list[str] = []
    document_count = 0
    annotation_count = 0
    manifest_path = root / "manifest.csv"
    if not manifest_path.is_file():
        return DatasetValidation(False, 0, 0, ["manifest.csv отсутствует"], [])
    if any(path.is_symlink() for path in root.rglob("*")):
        issues.append("Символические ссылки в датасете запрещены")

    with manifest_path.open("r", encoding="utf-8-sig", newline="") as stream:
        reader = csv.DictReader(stream)
        columns = set(reader.fieldnames or [])
        missing = sorted(REQUIRED_COLUMNS - columns)
        if missing:
            issues.append("В manifest.csv отсутствуют колонки: " + ", ".join(missing))
        rows = list(reader)

    seen_ids: set[str] = set()
    seen_hashes: set[str] = set()
    for row_number, row in enumerate(rows, start=2):
        document_id = (row.get("document_id") or "").strip()
        relative = (row.get("file_path") or "").strip()
        if not document_id or not re.fullmatch(r"doc-[a-zA-Z0-9_-]{4,64}", document_id):
            issues.append(f"Строка {row_number}: некорректный document_id")
            continue
        if document_id in seen_ids:
            issues.append(f"Строка {row_number}: повторный document_id")
        seen_ids.add(document_id)
        file_name = Path(relative).name
        if not re.fullmatch(rf"{re.escape(document_id)}\.(?:pdf|png|jpe?g|tiff?|heic|heif)", file_name, re.IGNORECASE):
            issues.append(f"Строка {row_number}: имя файла должно совпадать с document_id и безопасным расширением")
        if SUSPICIOUS_FILENAME.search(Path(relative).name):
            issues.append(f"Строка {row_number}: имя файла похоже на содержащее идентификатор")
        document_path = _safe_relative(root, relative)
        if document_path is None or not document_path.is_file():
            issues.append(f"Строка {row_number}: файл отсутствует или выходит за корень датасета")
            continue
        document_count += 1
        actual_hash = _sha256(document_path)
        expected_hash = (row.get("sha256") or "").lower()
        if actual_hash != expected_hash:
            issues.append(f"Строка {row_number}: SHA-256 не совпадает")
        if actual_hash in seen_hashes:
            warnings.append(f"Строка {row_number}: повтор содержимого документа")
        seen_hashes.add(actual_hash)

        annotation_path = root / "annotations" / f"{document_id}.json"
        if not annotation_path.is_file():
            issues.append(f"Строка {row_number}: отсутствует annotations/{document_id}.json")
            continue
        try:
            annotation = json.loads(annotation_path.read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError):
            issues.append(f"Строка {row_number}: аннотация не является корректным JSON")
            continue
        if annotation.get("schema") != "mika-deid-annotation-v1":
            issues.append(f"Строка {row_number}: неизвестная схема аннотации")
        if annotation.get("document_id") != document_id:
            issues.append(f"Строка {row_number}: document_id аннотации не совпадает")
        for page in annotation.get("pages", []):
            for region in page.get("regions", []):
                annotation_count += 1
                category = region.get("category")
                if category not in ALLOWED_CATEGORIES:
                    issues.append(f"{document_id}: неизвестная категория {category!r}")
                box = region.get("box") or {}
                values = [box.get(key) for key in ("x0", "y0", "x1", "y1")]
                if not all(isinstance(value, (int, float)) and 0 <= value <= 1 for value in values):
                    issues.append(f"{document_id}: координаты региона вне диапазона 0..1")
                elif not (values[0] < values[2] and values[1] < values[3]):
                    issues.append(f"{document_id}: пустой или инвертированный регион")
                notes = str(region.get("notes") or "")
                if re.search(r"@|\+?7[\s()\-]*\d{3}|\b\d{3}[\s-]?\d{3}[\s-]?\d{3}[\s-]?\d{2}\b", notes):
                    issues.append(f"{document_id}: notes может содержать идентификатор")

    if document_count < 50:
        warnings.append("Калибровочный набор содержит менее рекомендуемых 50 документов")
    if annotation_count == 0:
        issues.append("В датасете нет ни одной размеченной области")
    return DatasetValidation(not issues, document_count, annotation_count, issues, warnings)


def main() -> int:
    import argparse

    parser = argparse.ArgumentParser(description="Validate a MIKA De-ID dataset package without exposing annotation text")
    parser.add_argument("root", type=Path)
    args = parser.parse_args()
    result = validate_dataset(args.root)
    print(json.dumps(result.as_dict(), ensure_ascii=False, indent=2))
    return 0 if result.passed else 2


if __name__ == "__main__":
    raise SystemExit(main())
