#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
uei_validate.py - UEI Package 1.x 驗證器（Uedu Evidence Index）

只用 Python 標準函式庫，可單獨下載使用：
    https://uedu.tw/static/uei/uei_validate.py

用法：
    python uei_validate.py my-package.zip
    python uei_validate.py uei.json

結束碼：0＝通過（可能有警告）；1＝有錯誤；2＝無法讀取。

格式說明：https://uedu.tw/evidence/schema/uei-package-1.0.json
這份檔案在 Uedu 原始碼中是 utils/uei_validate.py，static/uei/uei_validate.py 是同一份的公開副本，
修改後兩份必須同步（scripts/test_uei_package.py 會檢查）。
"""
import hashlib
import json
import re
import sys
import zipfile

SUPPORTED_MAJOR = 1
SCHEMA_URL = 'https://uedu.tw/evidence/schema/uei-package-1.0.json'

ALLOWED_LICENSES = ('CC-BY-4.0', 'CC-BY-SA-4.0', 'CC-BY-NC-4.0', 'CC-BY-NC-SA-4.0')
RELATION_TYPES = ('IsIdenticalTo', 'IsDerivedFrom', 'IsSupplementedBy', 'IsSupplementTo',
                  'IsNewVersionOf', 'IsPreviousVersionOf', 'IsVersionOf', 'HasVersion',
                  'IsCitedBy', 'Cites', 'IsDocumentedBy', 'Documents', 'References',
                  'IsReferencedBy', 'IsPartOf', 'HasPart')
STUDY_DESIGNS = ('rct', 'cluster_rct', 'quasi', 'pre_post', 'single_group',
                 'observational', 'qualitative', 'mixed')
TIMEPOINTS = ('pre', 'post', 'delayed', 'transfer')
SMALL_CELL_N = 5

MAX_MANIFEST_BYTES = 5 * 1024 * 1024
MAX_FILE_BYTES = 50 * 1024 * 1024
MAX_FILES = 50
MAX_TOTAL_BYTES = 300 * 1024 * 1024

_UUID_RE = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')
_ORCID_RE = re.compile(r'^\d{4}-\d{4}-\d{4}-\d{3}[\dX]$')
_SHA_RE = re.compile(r'^[0-9a-f]{64}$')
_VERSION_RE = re.compile(r'^(\d+)\.(\d+)\.(\d+)$')


class Report:
    def __init__(self):
        self.errors = []
        self.warnings = []

    def error(self, where, msg):
        self.errors.append(f'{where}: {msg}')

    def warn(self, where, msg):
        self.warnings.append(f'{where}: {msg}')

    @property
    def ok(self):
        return not self.errors


def _is_str(v, allow_empty=False):
    return isinstance(v, str) and (allow_empty or v.strip() != '')


def _is_int(v):
    return isinstance(v, int) and not isinstance(v, bool)


def _is_num(v):
    return isinstance(v, (int, float)) and not isinstance(v, bool)


def safe_member_path(path):
    """package 內附檔路徑：必須在 files/ 之下，不可有 ..、絕對路徑或反斜線"""
    if not _is_str(path) or '\\' in path or path.startswith('/'):
        return False
    if not path.startswith('files/') or len(path) <= len('files/'):
        return False
    parts = path.split('/')
    return all(p not in ('', '.', '..') for p in parts)


def _check_license(rep, where, lic):
    if not isinstance(lic, dict):
        rep.error(where, 'license 必須是物件 {id, url}')
        return
    if lic.get('id') not in ALLOWED_LICENSES:
        rep.error(where, f'license.id 必須是 {", ".join(ALLOWED_LICENSES)} 之一（僅供瀏覽、不可再散布的授權不能放進 UEI）')


def _check_creators(rep, where, creators):
    if not isinstance(creators, list) or not creators:
        rep.error(where, 'creators 至少要有一位具名作者')
        return
    for i, c in enumerate(creators):
        w = f'{where}[{i}]'
        if not isinstance(c, dict) or not _is_str(c.get('name')):
            rep.error(w, '作者必須有 name')
            continue
        if c.get('orcid') and not _ORCID_RE.match(str(c['orcid'])):
            rep.error(w, 'ORCID 格式應為 0000-0000-0000-0000')
        if not c.get('affiliation'):
            rep.warn(w, '建議填寫 affiliation（服務單位）')


def _check_related(rep, where, items):
    if items is None:
        return
    if not isinstance(items, list):
        rep.error(where, 'related_identifiers 必須是陣列')
        return
    for i, r in enumerate(items):
        w = f'{where}[{i}]'
        if not isinstance(r, dict) or not _is_str(r.get('identifier')):
            rep.error(w, '必須有 identifier')
            continue
        if r.get('relation_type') not in RELATION_TYPES:
            rep.warn(w, f'relation_type「{r.get("relation_type")}」不在 DataCite 子集內')


def _check_protocol(rep, p):
    w = 'protocol'
    if not isinstance(p, dict):
        rep.error(w, '必須是物件')
        return
    if not _is_str(p.get('title')) or len(p['title']) > 255:
        rep.error(w, 'title 必填、最多 255 字元')
    if not _is_str(p.get('abstract')):
        rep.error(w, 'abstract 必填')
    if not _is_int(p.get('version')) or p['version'] < 1:
        rep.error(w, 'version 必須是正整數')
    _check_license(rep, f'{w}.license', p.get('license'))
    _check_creators(rep, f'{w}.creators', p.get('creators'))
    ids = p.get('identifiers')
    if not isinstance(ids, dict):
        rep.error(w, 'identifiers 必須是物件')
    elif ids.get('uei_uuid') and not _UUID_RE.match(str(ids['uei_uuid'])):
        rep.error(f'{w}.identifiers', 'uei_uuid 必須是小寫 UUID')
    cls = p.get('classification')
    if cls is not None and not isinstance(cls, dict):
        rep.error(w, 'classification 必須是物件')
    _check_related(rep, f'{w}.related_identifiers', p.get('related_identifiers'))
    notices = p.get('notices') or {}
    if notices.get('withdrawn'):
        rep.warn(w, '這個教學設計已撤回；匯入端應顯示撤回說明')


def _check_components(rep, comps):
    if not isinstance(comps, list) or not comps:
        rep.error('components', '至少要有一個元件')
        return []
    listed = []
    for i, c in enumerate(comps):
        w = f'components[{i}]'
        if not isinstance(c, dict):
            rep.error(w, '必須是物件')
            continue
        if not _is_str(c.get('type')):
            rep.error(w, 'type 必填（開放字串，例如 teaching_material、ai_task）')
        if not _is_str(c.get('title')):
            rep.error(w, 'title 必填')
        _check_license(rep, f'{w}.license', c.get('license'))
        if c.get('payload') is not None and not isinstance(c['payload'], dict):
            rep.error(w, 'payload 必須是物件')
        if c.get('payload') is not None and not _is_str(c.get('payload_format')):
            rep.warn(w, '有 payload 時建議標示 payload_format')
        files = c.get('files') or []
        if not isinstance(files, list):
            rep.error(w, 'files 必須是陣列')
            continue
        for j, f in enumerate(files):
            fw = f'{w}.files[{j}]'
            if not isinstance(f, dict):
                rep.error(fw, '必須是物件')
                continue
            if not safe_member_path(f.get('path')):
                rep.error(fw, 'path 必須是 files/ 之下的相對路徑，不可含 ..')
            if not _SHA_RE.match(str(f.get('sha256') or '')):
                rep.error(fw, 'sha256 必須是 64 位小寫十六進位')
            if not _is_int(f.get('size')) or f['size'] < 0:
                rep.error(fw, 'size 必須是非負整數')
            elif f['size'] > MAX_FILE_BYTES:
                rep.error(fw, f'單檔超過 {MAX_FILE_BYTES // 1024 // 1024} MB')
            if not _is_str(f.get('original_name')):
                rep.warn(fw, '建議填寫 original_name')
            listed.append(f)
    if len(listed) > MAX_FILES:
        rep.error('components', f'附檔總數超過 {MAX_FILES}')
    return listed


def _check_outcome(rep, w, o):
    if not isinstance(o, dict):
        rep.error(w, '必須是物件')
        return
    if not _is_str(o.get('construct')):
        rep.error(w, 'construct 必填')
    if o.get('timepoint') not in TIMEPOINTS:
        rep.error(w, f'timepoint 必須是 {", ".join(TIMEPOINTS)} 之一')
    otype = o.get('type')
    if otype not in ('continuous', 'binary'):
        rep.error(w, 'type 必須是 continuous 或 binary')
    groups = o.get('groups')
    if not isinstance(groups, list) or not groups:
        rep.error(w, 'groups 至少一組')
        return
    for k, g in enumerate(groups):
        gw = f'{w}.groups[{k}]'
        if not isinstance(g, dict):
            rep.error(gw, '必須是物件')
            continue
        if g.get('role') not in ('intervention', 'control'):
            rep.error(gw, 'role 必須是 intervention 或 control')
        n = g.get('n')
        if n is not None and (not _is_int(n) or n < 0):
            rep.error(gw, 'n 必須是非負整數')
        if _is_int(n) and n < SMALL_CELL_N:
            rep.warn(gw, f'n < {SMALL_CELL_N}，公開頁與匯出會隱藏這一列')
        if otype == 'continuous':
            for key in ('n', 'mean', 'sd'):
                if g.get(key) is not None and not _is_num(g.get(key)):
                    rep.error(gw, f'{key} 必須是數字')
            if any(g.get(key) is None for key in ('n', 'mean', 'sd')):
                rep.warn(gw, '連續型結果缺 n／mean／sd 其中一項，無法用於統合分析')
        elif otype == 'binary':
            if g.get('events') is not None and not _is_int(g.get('events')):
                rep.error(gw, 'events 必須是整數')
            if g.get('n') is None or g.get('events') is None:
                rep.warn(gw, '二元型結果缺 n 或 events，無法用於統合分析')
    es = o.get('effect_size')
    if es is not None:
        if not isinstance(es, dict):
            rep.error(w, 'effect_size 必須是物件')
        elif es.get('value') is not None and not _is_num(es.get('value')):
            rep.error(w, 'effect_size.value 必須是數字')


def _check_studies(rep, studies):
    if studies is None:
        return
    if not isinstance(studies, list):
        rep.error('studies', '必須是陣列（沒有研究時給空陣列）')
        return
    for i, s in enumerate(studies):
        w = f'studies[{i}]'
        if not isinstance(s, dict):
            rep.error(w, '必須是物件')
            continue
        if not _is_str(s.get('title')):
            rep.error(w, 'title 必填')
        if s.get('design') not in STUDY_DESIGNS:
            rep.error(w, f'design 必須是 {", ".join(STUDY_DESIGNS)} 之一')
        for j, o in enumerate(s.get('outcomes') or []):
            _check_outcome(rep, f'{w}.outcomes[{j}]', o)


def validate_manifest(m, rep=None):
    """驗證 uei.json 內容（dict）。Returns Report"""
    rep = rep or Report()
    if not isinstance(m, dict):
        rep.error('uei.json', '最外層必須是物件')
        return rep
    sv = m.get('schema_version')
    mv = _VERSION_RE.match(str(sv or ''))
    if not mv:
        rep.error('schema_version', '必須是 1.x.y 的語意化版本')
    elif int(mv.group(1)) != SUPPORTED_MAJOR:
        rep.error('schema_version', f'不支援的主版本 {mv.group(1)}（本驗證器支援 {SUPPORTED_MAJOR}.x）')
    if m.get('schema') and m.get('schema') != SCHEMA_URL:
        rep.warn('schema', f'建議填 {SCHEMA_URL}')
    if not _UUID_RE.match(str(m.get('package_id') or '')):
        rep.error('package_id', '必須是小寫 UUID')
    _check_protocol(rep, m.get('protocol'))
    _check_components(rep, m.get('components'))
    _check_studies(rep, m.get('studies'))
    return rep


def read_package(path_or_file):
    """
    讀 zip 或 json。Returns (manifest dict or None, zipfile or None, Report)
    zip 內必須有 uei.json；附檔逐一比對大小與 sha256。
    """
    rep = Report()
    name = getattr(path_or_file, 'name', None) or str(path_or_file)
    try:
        if zipfile.is_zipfile(path_or_file):
            if hasattr(path_or_file, 'seek'):
                path_or_file.seek(0)
            zf = zipfile.ZipFile(path_or_file)
            names = zf.namelist()
            if 'uei.json' not in names:
                rep.error('package', 'zip 根目錄必須有 uei.json')
                return None, zf, rep
            info = zf.getinfo('uei.json')
            if info.file_size > MAX_MANIFEST_BYTES:
                rep.error('uei.json', '超過 5 MB')
                return None, zf, rep
            manifest = json.loads(zf.read('uei.json').decode('utf-8'))
            validate_manifest(manifest, rep)
            total = 0
            for n in names:
                if n in ('uei.json',) or n.endswith('/'):
                    continue
                if not safe_member_path(n):
                    rep.error('package', f'不允許的檔案路徑：{n}')
            listed = []
            for c in manifest.get('components') or []:
                if isinstance(c, dict):
                    listed.extend(f for f in (c.get('files') or []) if isinstance(f, dict))
            for f in listed:
                p = f.get('path')
                if not safe_member_path(p):
                    continue
                if p not in names:
                    rep.error('package', f'uei.json 列了 {p}，但 zip 內沒有這個檔案')
                    continue
                zi = zf.getinfo(p)
                total += zi.file_size
                if zi.file_size > MAX_FILE_BYTES:
                    rep.error(p, '單檔超過上限')
                    continue
                if zi.file_size != f.get('size'):
                    rep.error(p, '大小與 uei.json 不符')
                h = hashlib.sha256()
                with zf.open(p) as fh:
                    for chunk in iter(lambda: fh.read(1024 * 1024), b''):
                        h.update(chunk)
                if h.hexdigest() != f.get('sha256'):
                    rep.error(p, 'sha256 與 uei.json 不符')
            if total > MAX_TOTAL_BYTES:
                rep.error('package', '附檔總大小超過上限')
            return manifest, zf, rep
        if hasattr(path_or_file, 'read'):
            path_or_file.seek(0)
            raw = path_or_file.read()
        else:
            with open(path_or_file, 'rb') as fh:
                raw = fh.read()
        if len(raw) > MAX_MANIFEST_BYTES:
            rep.error('uei.json', '超過 5 MB')
            return None, None, rep
        manifest = json.loads(raw.decode('utf-8') if isinstance(raw, bytes) else raw)
        validate_manifest(manifest, rep)
        for c in manifest.get('components') or []:
            if isinstance(c, dict) and c.get('files'):
                rep.error('package', '單獨的 uei.json 不能帶附檔；有附檔時請上傳 zip')
                break
        return manifest, None, rep
    except (ValueError, UnicodeDecodeError) as e:
        rep.error(name, f'不是有效的 JSON：{e}')
    except (OSError, zipfile.BadZipFile) as e:
        rep.error(name, f'無法讀取：{e}')
    return None, None, rep


def main(argv):
    if len(argv) != 2:
        print(__doc__)
        return 2
    manifest, zf, rep = read_package(argv[1])
    for w in rep.warnings:
        print(f'警告  {w}')
    for e in rep.errors:
        print(f'錯誤  {e}')
    if manifest is None and not rep.errors:
        return 2
    print('通過' if rep.ok else f'未通過（{len(rep.errors)} 個錯誤）')
    return 0 if rep.ok else 1


if __name__ == '__main__':
    sys.exit(main(sys.argv))
