import json
import os
import shutil
import tempfile
import zipfile
from pathlib import Path, PurePosixPath

from .protected_package import (
    ProtectedPackageError,
    ProtectedPackageStream,
    is_protected_package_path,
)

FORMAT = "3d-core/sketchfab-osgjs-blender"
SCHEMA_VERSION = 1
MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024
MAX_PACKAGE_BYTES = 8 * 1024 * 1024 * 1024


class PackageError(ValueError):
    pass


class SceneIRPackage:
    def __init__(self, source_path, persist_dir=None, key_resolver=None):
        self.source_path = Path(source_path)
        if self.source_path.name == "blender-package.json":
            self.source_path = self.source_path.parent
        self._zip = None
        self._protected_stream = None
        self._temp_dir = None
        self._persist_dir = Path(persist_dir) if persist_dir else None
        self._key_resolver = key_resolver
        self.manifest = None
        self.scene = None
        self.protected_header = None

    def __enter__(self):
        try:
            if self.source_path.is_dir():
                self._root = self.source_path.resolve()
            elif is_protected_package_path(self.source_path):
                try:
                    self._protected_stream = ProtectedPackageStream(
                        self.source_path, self._key_resolver
                    )
                    self.protected_header = self._protected_stream.header
                    self._zip = zipfile.ZipFile(self._protected_stream, "r")
                except ProtectedPackageError as error:
                    raise PackageError(str(error)) from error
                except zipfile.BadZipFile as error:
                    raise PackageError(
                        "The decrypted .3dripper payload is not a valid Scene IR ZIP"
                    ) from error
                self._validate_zip_entries()
                self._root = None
            elif zipfile.is_zipfile(self.source_path):
                self._zip = zipfile.ZipFile(self.source_path, "r")
                self._validate_zip_entries()
                self._root = None
            else:
                raise PackageError(
                    "Choose a .3dripper package, Blender Scene IR .zip, "
                    "blender-package.json, or unpacked package directory"
                )

            self.manifest = self.read_json("blender-package.json")
            self._validate_manifest()
            self.scene = self.read_json(self.manifest["entry"])
            self._validate_scene()
            self._validate_manifest_files()
            return self
        except BaseException:
            # Validation failed before the caller could enter the with-block;
            # release the zip handle so the package file is not left locked.
            self.__exit__(None, None, None)
            raise

    def __exit__(self, _exc_type, _exc, _traceback):
        if self._zip:
            self._zip.close()
        if self._protected_stream:
            self._protected_stream.close()
        if self._temp_dir:
            shutil.rmtree(self._temp_dir, ignore_errors=True)

    def read_bytes(self, relative_path):
        path = _safe_relative_path(relative_path)
        if self._zip:
            try:
                return self._zip.read(path)
            except KeyError as error:
                raise PackageError("Package file is missing: {}".format(path)) from error
        target = self._resolve_dir_path(path)
        if target.stat().st_size > MAX_FILE_BYTES:
            raise PackageError("Package file is too large: {}".format(path))
        return target.read_bytes()

    def entry_size(self, relative_path):
        path = _safe_relative_path(relative_path)
        if self._zip:
            try:
                return self._zip.getinfo(path).file_size
            except KeyError as error:
                raise PackageError("Package file is missing: {}".format(path)) from error
        return self._resolve_dir_path(path).stat().st_size

    def _resolve_dir_path(self, path):
        target = (self._root / Path(*PurePosixPath(path).parts)).resolve()
        if os.path.commonpath((str(self._root), str(target))) != str(self._root):
            raise PackageError("Package path escapes its root: {}".format(path))
        if not target.is_file():
            raise PackageError("Package file is missing: {}".format(path))
        return target

    def read_json(self, relative_path):
        try:
            return json.loads(self.read_bytes(relative_path).decode("utf-8"))
        except (UnicodeDecodeError, json.JSONDecodeError) as error:
            raise PackageError("Invalid JSON file: {}".format(relative_path)) from error

    def materialize_resource(self, relative_path):
        path = _safe_relative_path(relative_path)
        if self._persist_dir:
            base = self._persist_dir
            base.mkdir(parents=True, exist_ok=True)
            base = base.resolve()
        else:
            if not self._temp_dir:
                self._temp_dir = Path(tempfile.mkdtemp(prefix="sketchfab_scene_ir_"))
            base = self._temp_dir
        target = (base / Path(*PurePosixPath(path).parts)).resolve()
        if os.path.commonpath((str(base), str(target))) != str(base):
            raise PackageError("Resource path escapes its extraction directory: {}".format(path))
        if target.is_file() and target.stat().st_size == self.entry_size(path):
            return target
        target.parent.mkdir(parents=True, exist_ok=True)
        target.write_bytes(self.read_bytes(path))
        return target

    def _validate_zip_entries(self):
        total = 0
        seen = set()
        for info in self._zip.infolist():
            _safe_relative_path(info.filename)
            if info.filename in seen:
                raise PackageError("Package contains duplicate entry: {}".format(info.filename))
            seen.add(info.filename)
            if info.file_size > MAX_FILE_BYTES:
                raise PackageError("Package entry is too large: {}".format(info.filename))
            total += info.file_size
            if total > MAX_PACKAGE_BYTES:
                raise PackageError("Package expands beyond the supported size limit")

    def _validate_manifest(self):
        if not isinstance(self.manifest, dict):
            raise PackageError("blender-package.json must contain an object")
        if self.manifest.get("format") != FORMAT:
            raise PackageError("Unsupported package format: {}".format(self.manifest.get("format")))
        if self.manifest.get("schemaVersion") != SCHEMA_VERSION:
            raise PackageError("Unsupported Scene IR schema version: {}".format(self.manifest.get("schemaVersion")))
        _safe_relative_path(self.manifest.get("entry", ""))
        if not isinstance(self.manifest.get("files"), list):
            raise PackageError("Package manifest has no file table")

    def _validate_scene(self):
        if not isinstance(self.scene, dict):
            raise PackageError("scene.json must contain an object")
        if self.scene.get("format") != FORMAT or self.scene.get("schemaVersion") != SCHEMA_VERSION:
            raise PackageError("scene.json does not match its package manifest")
        for key in ("nodes", "meshes", "materials", "textures", "cameras", "lights", "skins", "animations"):
            if not isinstance(self.scene.get(key), list):
                raise PackageError("scene.json field '{}' must be an array".format(key))

    def _validate_manifest_files(self):
        listed = set()
        for entry in self.manifest["files"]:
            if not isinstance(entry, dict):
                raise PackageError("Invalid package file table entry")
            path = _safe_relative_path(entry.get("path", ""))
            if path in listed:
                raise PackageError("Duplicate package file entry: {}".format(path))
            listed.add(path)
            if self.entry_size(path) != entry.get("byteLength"):
                raise PackageError("Package file length mismatch: {}".format(path))
        if self.manifest["entry"] not in listed:
            raise PackageError("Scene entry is not listed in package manifest")


def _safe_relative_path(value):
    if not isinstance(value, str) or not value:
        raise PackageError("Package contains an empty path")
    if "\\" in value or "\x00" in value:
        raise PackageError("Package contains an unsafe path: {}".format(value))
    path = PurePosixPath(value)
    if path.is_absolute() or any(part in ("", ".", "..") for part in path.parts):
        raise PackageError("Package contains an unsafe path: {}".format(value))
    return path.as_posix()
