#!/usr/bin/env python3
"""
Скрипт сборки OTA-пакетов для ESP32.
Обходит /opt/ota/first/, компилирует .py -> .mpy,
генерирует manifest.json и manifest.sig.
"""

import os
import sys
import json
import hmac
import hashlib
import subprocess
import shutil
from pathlib import Path

FIRST_DIR = Path("/opt/ota/first")
OTA_DIR = Path("/opt/ota/ota")
SECRET_FILE = Path("/etc/ota-secret")
LOG_FILE = Path("/var/log/ota-build.log")
MPY_CROSS = "/home/malvinov/.local/bin/mpy-cross"


def log(msg):
    """Пишет в stdout и в лог-файл."""
    print(msg)
    try:
        with open(LOG_FILE, "a") as f:
            f.write(msg + "\n")
    except Exception:
        pass


def load_secret():
    """Читает HMAC-секрет (hex) из файла."""
    with open(SECRET_FILE, "rb") as f:
        return bytes.fromhex(f.read().strip().decode())


def hmac_file(path, secret):
    """HMAC-SHA256 от содержимого файла (hex)."""
    h = hmac.new(secret, digestmod=hashlib.sha256)
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(8192), b""):
            h.update(chunk)
    return h.hexdigest()


def build_version(version_dir, secret):
    """Собирает одну версию из .py в .mpy + манифест."""
    version = version_dir.name
    py_files = sorted(version_dir.glob("*.py"))

    if not py_files:
        log(f"[{version}] Нет .py файлов, пропускаем")
        return

    out_dir = OTA_DIR / version
    out_dir.mkdir(parents=True, exist_ok=True)

    # --- Проверка: нужно ли пересобирать? ---
    manifest_path = out_dir / "manifest.json"
    if manifest_path.exists():
        try:
            with open(manifest_path) as f:
                old_manifest = json.load(f)

            old_names = sorted(item["name"] for item in old_manifest["files"])
            new_names = sorted(p.stem + ".mpy" for p in py_files)

            if old_names == new_names:
                needs_rebuild = False
                for py in py_files:
                    mpy_name = py.stem + ".mpy"
                    old_item = next(
                        (i for i in old_manifest["files"] if i["name"] == mpy_name),
                        None,
                    )
                    if not old_item:
                        needs_rebuild = True
                        break
                    current_src_hmac = hmac_file(py, secret)
                    if old_item.get("source_hmac") != current_src_hmac:
                        needs_rebuild = True
                        break

                if not needs_rebuild:
                    log(f"[{version}] Без изменений, пропускаем")
                    return
        except Exception as e:
            log(f"[{version}] Ошибка чтения старого манифеста: {e}, пересобираем")

    log(f"[{version}] Сборка...")

    # --- Компиляция .py -> .mpy ---
    files_meta = []
    for py in py_files:
        mpy_path = out_dir / (py.stem + ".mpy")

        result = subprocess.run(
            [MPY_CROSS, "-o", str(mpy_path), str(py)],
            capture_output=True,
            text=True,
        )

        if result.returncode != 0:
            log(f"[{version}] ОШИБКА компиляции {py.name}: {result.stderr.strip()}")
            return

        file_hmac = hmac_file(mpy_path, secret)
        source_hmac = hmac_file(py, secret)

        files_meta.append({
            "name": mpy_path.name,
            "size": mpy_path.stat().st_size,
            "hmac": file_hmac,
            "source_hmac": source_hmac,
        })

    # --- Генерация манифеста ---
    manifest = {
        "version": version,
        "files": files_meta,
    }

    manifest_path.write_text(json.dumps(manifest, indent=2))

    # --- Подпись манифеста ---
    manifest_sig = hmac_file(manifest_path, secret)
    (out_dir / "manifest.sig").write_text(manifest_sig + "\n")

    # --- Удаляем .mpy, которых нет в новых исходниках ---
    new_mpy_names = {p.stem + ".mpy" for p in py_files}
    existing_mpy = {f.name for f in out_dir.glob("*.mpy")}
    to_delete = existing_mpy - new_mpy_names
    for name in to_delete:
        (out_dir / name).unlink()
        log(f"[{version}] Удалён {name} (нет в first/)")

    # Меняем владельца на www-data, чтобы Nginx читал
    subprocess.run(["chown", "-R", "www-data:www-data", str(out_dir)])

    log(f"[{version}] Готово: {len(files_meta)} файлов, манифест и подпись созданы")


def main():
    if not SECRET_FILE.exists():
        log("ОШИБКА: /etc/ota-secret не найден")
        sys.exit(1)

    if not FIRST_DIR.exists():
        log("ОШИБКА: /opt/ota/first не существует")
        sys.exit(1)

    secret = load_secret()

    # --- Сборка всех версий из first/ ---
    for version_dir in sorted(FIRST_DIR.iterdir()):
        if version_dir.is_dir():
            try:
                build_version(version_dir, secret)
            except Exception as e:
                log(f"[{version_dir.name}] ИСКЛЮЧЕНИЕ: {e}")

    # --- Синхронизация: удаляем версии из ota/, которых нет в first/ ---
    first_versions = {d.name for d in FIRST_DIR.iterdir() if d.is_dir()}
    ota_versions = {d.name for d in OTA_DIR.iterdir() if d.is_dir()} if OTA_DIR.exists() else set()

    to_delete = ota_versions - first_versions
    for version in sorted(to_delete):
        version_path = OTA_DIR / version
        try:
            shutil.rmtree(version_path)
            log(f"[{version}] Удалено из ota/ (нет в first/)")
        except Exception as e:
            log(f"[{version}] Ошибка удаления: {e}")

    # --- Генерация versions.json ---
    first_versions_sorted = sorted(
        [d.name for d in FIRST_DIR.iterdir() if d.is_dir()],
        key=lambda v: tuple(int(x) for x in v.split('.'))
    )

    versions_json = {
        "latest": first_versions_sorted[-1] if first_versions_sorted else None,
        "versions": first_versions_sorted
    }

    versions_path = OTA_DIR / "versions.json"
    versions_path.write_text(json.dumps(versions_json, indent=2))

    subprocess.run(["chown", "www-data:www-data", str(versions_path)])
    subprocess.run(["chmod", "644", str(versions_path)])

    log(f"versions.json обновлён: latest={versions_json['latest']}")


if __name__ == "__main__":
    main()