"""Immutable staged-to-committed object workflow shared by local and cloud backends."""

from __future__ import annotations

import hashlib
import os
import stat
import tempfile
from pathlib import Path
from typing import Dict, Mapping, Optional, Tuple

from arr_ingestion.contracts import ROLE_CONTRACTS, SHA256_RE, IngestionError
from arr_storage.contracts import (
    OBJECT_METADATA_SCHEMA,
    BackendError,
    BackendObject,
    ObjectAddress,
    ObjectBackend,
    ObjectKeyPolicy,
    StoredObject,
    role_limit,
    valid_delivery_filename,
)


_REQUIRED_METADATA = frozenset(
    {
        "arr-object-schema",
        "arr-state",
        "arr-role",
        "arr-job-id",
        "arr-attempt-no",
        "arr-sha256",
        "arr-byte-size",
        "arr-mime-type",
    }
)


class ManagedObjectStore:
    """ARR-owned immutable object store and ingestion ArtifactStore implementation."""

    def __init__(self, backend: ObjectBackend, policy: Optional[ObjectKeyPolicy] = None) -> None:
        self._backend = backend
        self._policy = policy or ObjectKeyPolicy()

    @property
    def key_policy(self) -> ObjectKeyPolicy:
        return self._policy

    def stage_file(
        self,
        *,
        job_id: str,
        attempt_no: int,
        role: str,
        source: Path,
        original_filename: str,
        expected_sha256: Optional[str] = None,
        expected_byte_size: Optional[int] = None,
    ) -> StoredObject:
        address = ObjectAddress(job_id, attempt_no, "staged", role)
        object_key = self._policy.build(address)
        if not valid_delivery_filename(role, original_filename):
            raise IngestionError(
                "ARTIFACT_REFERENCE_INVALID", "artifact filename is invalid"
            )
        if expected_sha256 is not None and not SHA256_RE.fullmatch(expected_sha256):
            raise IngestionError(
                "ARTIFACT_REFERENCE_INVALID", "artifact hash is invalid"
            )
        if (
            expected_byte_size is not None
            and (
                not isinstance(expected_byte_size, int)
                or isinstance(expected_byte_size, bool)
                or expected_byte_size < 0
            )
        ):
            raise IngestionError(
                "ARTIFACT_REFERENCE_INVALID", "artifact size is invalid"
            )
        _file_kind, _extension, mime_type = ROLE_CONTRACTS[role]
        with tempfile.TemporaryDirectory(prefix="arr-object-stage-") as temporary:
            snapshot = Path(temporary) / "upload.bin"
            sha256, byte_size = self._snapshot(source, snapshot, role_limit(role))
            if expected_sha256 is not None and sha256 != expected_sha256:
                raise IngestionError(
                    "ARTIFACT_HASH_MISMATCH", "artifact identity does not match"
                )
            if expected_byte_size is not None and byte_size != expected_byte_size:
                raise IngestionError(
                    "ARTIFACT_HASH_MISMATCH", "artifact identity does not match"
                )
            metadata = self._metadata(address, sha256, byte_size, mime_type)
            try:
                self._backend.put_file(
                    object_key,
                    str(snapshot),
                    mime_type,
                    metadata,
                    if_absent=True,
                )
            except BackendError as error:
                if error.kind != "conflict":
                    self._raise_backend(error)
                existing = self.inspect(object_key, original_filename)
                if (existing.sha256, existing.byte_size, existing.mime_type) != (
                    sha256,
                    byte_size,
                    mime_type,
                ):
                    raise IngestionError(
                        "ARTIFACT_CONFLICT", "object key already contains different bytes"
                    ) from None
                return existing
        return self.inspect(object_key, original_filename)

    def commit(self, staged: StoredObject) -> StoredObject:
        self._validate_descriptor(staged, required_state="staged")
        committed_address = ObjectAddress(
            staged.job_id,
            staged.attempt_no,
            "committed",
            staged.role,
        )
        committed_key = self._policy.build(committed_address)
        try:
            verified_staged = self.inspect(staged.object_key, staged.original_filename)
        except IngestionError as error:
            if error.code != "ARTIFACT_NOT_FOUND":
                raise
            committed = self.inspect(committed_key, staged.original_filename)
            self._require_same_identity(staged, committed)
            return committed
        self._require_same_identity(staged, verified_staged)
        metadata = self._metadata(
            committed_address,
            staged.sha256,
            staged.byte_size,
            staged.mime_type,
        )
        try:
            self._backend.copy_object(
                staged.object_key,
                committed_key,
                metadata,
                if_absent=True,
            )
        except BackendError as error:
            if error.kind != "conflict":
                self._raise_backend(error)
            committed = self.inspect(committed_key, staged.original_filename)
            self._require_same_identity(staged, committed)
        else:
            committed = self.inspect(committed_key, staged.original_filename)
            self._require_same_identity(staged, committed)
        try:
            self._backend.delete_object(staged.object_key)
        except BackendError as error:
            self._raise_backend(error)
        return committed

    def upload_committed(
        self,
        *,
        job_id: str,
        attempt_no: int,
        role: str,
        source: Path,
        original_filename: str,
        expected_sha256: Optional[str] = None,
        expected_byte_size: Optional[int] = None,
    ) -> StoredObject:
        staged = self.stage_file(
            job_id=job_id,
            attempt_no=attempt_no,
            role=role,
            source=source,
            original_filename=original_filename,
            expected_sha256=expected_sha256,
            expected_byte_size=expected_byte_size,
        )
        return self.commit(staged)

    def inspect(self, object_key: str, original_filename: str) -> StoredObject:
        address = self._policy.parse(object_key)
        if not valid_delivery_filename(address.role, original_filename):
            raise IngestionError(
                "ARTIFACT_REFERENCE_INVALID", "artifact filename is invalid"
            )
        try:
            head = self._backend.head(object_key)
        except BackendError as error:
            self._raise_backend(error)
        descriptor = self._descriptor_from_head(address, original_filename, head)
        actual_sha, actual_size = self._hash_object(object_key, role_limit(address.role))
        if (actual_sha, actual_size) != (descriptor.sha256, descriptor.byte_size):
            raise IngestionError(
                "ARTIFACT_HASH_MISMATCH", "stored object identity does not match metadata"
            )
        return descriptor

    def inspect_committed(self, object_key: str, original_filename: str) -> StoredObject:
        descriptor = self.inspect(object_key, original_filename)
        if descriptor.state != "committed":
            raise IngestionError(
                "ARTIFACT_NOT_COMMITTED", "only committed objects may be read"
            )
        return descriptor

    def materialize(self, object_key: str, destination: Path, max_bytes: int) -> None:
        address = self._policy.parse(object_key)
        if address.state != "committed":
            raise IngestionError(
                "ARTIFACT_NOT_COMMITTED", "only committed objects may be read"
            )
        if not isinstance(max_bytes, int) or isinstance(max_bytes, bool) or max_bytes < 0:
            raise IngestionError("ARTIFACT_REFERENCE_INVALID", "artifact size limit is invalid")
        try:
            head = self._backend.head(object_key)
        except BackendError as error:
            self._raise_backend(error)
        descriptor = self._descriptor_from_head(address, "placeholder" + ROLE_CONTRACTS[address.role][1], head)
        if descriptor.byte_size > max_bytes:
            raise IngestionError(
                "ARTIFACT_TOO_LARGE", "delivery artifact exceeds its size limit"
            )
        destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
        output_descriptor = -1
        destination_created = False
        copied = 0
        digest = hashlib.sha256()
        try:
            output_descriptor = os.open(
                destination,
                os.O_WRONLY | os.O_CREAT | os.O_EXCL,
                0o600,
            )
            destination_created = True
            with self._open_reader(object_key) as reader, os.fdopen(output_descriptor, "wb") as target:
                output_descriptor = -1
                while True:
                    chunk = self._read_chunk(reader)
                    if not chunk:
                        break
                    if not isinstance(chunk, bytes):
                        raise IngestionError(
                            "OBJECT_STORE_UNAVAILABLE", "object storage read did not complete"
                        )
                    copied += len(chunk)
                    if copied > max_bytes:
                        raise IngestionError(
                            "ARTIFACT_TOO_LARGE", "delivery artifact exceeds its size limit"
                        )
                    digest.update(chunk)
                    target.write(chunk)
                target.flush()
                os.fsync(target.fileno())
            if (copied, digest.hexdigest()) != (descriptor.byte_size, descriptor.sha256):
                raise IngestionError(
                    "ARTIFACT_HASH_MISMATCH", "stored object identity does not match metadata"
                )
        except FileExistsError:
            raise IngestionError(
                "ARTIFACT_CONFLICT", "materialization destination already exists"
            ) from None
        except Exception:
            if destination_created:
                destination.unlink(missing_ok=True)
            raise
        finally:
            if output_descriptor >= 0:
                os.close(output_descriptor)

    def _descriptor_from_head(
        self,
        address: ObjectAddress,
        original_filename: str,
        head: BackendObject,
    ) -> StoredObject:
        if head.object_key != self._policy.build(address):
            raise IngestionError("ARTIFACT_REFERENCE_INVALID", "stored object key is invalid")
        metadata = dict(head.metadata)
        if not _REQUIRED_METADATA.issubset(metadata):
            raise IngestionError(
                "ARTIFACT_METADATA_INVALID", "stored object metadata is incomplete"
            )
        _file_kind, _extension, expected_mime = ROLE_CONTRACTS[address.role]
        expected_values: Mapping[str, str] = {
            "arr-object-schema": OBJECT_METADATA_SCHEMA,
            "arr-state": address.state,
            "arr-role": address.role,
            "arr-job-id": address.job_id,
            "arr-attempt-no": str(address.attempt_no),
            "arr-mime-type": expected_mime,
        }
        if any(metadata.get(key) != value for key, value in expected_values.items()):
            raise IngestionError(
                "ARTIFACT_METADATA_INVALID", "stored object metadata conflicts with its key"
            )
        sha256 = metadata.get("arr-sha256", "")
        size_text = metadata.get("arr-byte-size", "")
        if not SHA256_RE.fullmatch(sha256) or not size_text.isdigit():
            raise IngestionError(
                "ARTIFACT_METADATA_INVALID", "stored object identity metadata is invalid"
            )
        byte_size = int(size_text)
        if byte_size != head.byte_size or byte_size > role_limit(address.role):
            raise IngestionError(
                "ARTIFACT_METADATA_INVALID", "stored object size metadata is invalid"
            )
        backend_content_type = metadata.get("arr-backend-content-type")
        if backend_content_type is not None and backend_content_type != expected_mime:
            raise IngestionError(
                "ARTIFACT_METADATA_INVALID", "stored object content type is invalid"
            )
        return StoredObject(
            object_key=head.object_key,
            job_id=address.job_id,
            attempt_no=address.attempt_no,
            state=address.state,
            role=address.role,
            original_filename=original_filename,
            sha256=sha256,
            byte_size=byte_size,
            mime_type=expected_mime,
            etag=head.etag,
            version_id=head.version_id,
        )

    @staticmethod
    def _metadata(
        address: ObjectAddress,
        sha256: str,
        byte_size: int,
        mime_type: str,
    ) -> Dict[str, str]:
        return {
            "arr-object-schema": OBJECT_METADATA_SCHEMA,
            "arr-state": address.state,
            "arr-role": address.role,
            "arr-job-id": address.job_id,
            "arr-attempt-no": str(address.attempt_no),
            "arr-sha256": sha256,
            "arr-byte-size": str(byte_size),
            "arr-mime-type": mime_type,
        }

    @staticmethod
    def _snapshot(source: Path, destination: Path, max_bytes: int) -> Tuple[str, int]:
        source_descriptor = -1
        destination_descriptor = -1
        copied = 0
        digest = hashlib.sha256()
        try:
            if source.is_symlink():
                raise IngestionError(
                    "ARTIFACT_REFERENCE_INVALID", "upload source must be a regular file"
                )
            source_descriptor = os.open(
                source,
                os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0),
            )
            if not stat.S_ISREG(os.fstat(source_descriptor).st_mode):
                raise IngestionError(
                    "ARTIFACT_REFERENCE_INVALID", "upload source must be a regular file"
                )
            destination_descriptor = os.open(
                destination,
                os.O_WRONLY | os.O_CREAT | os.O_EXCL,
                0o600,
            )
            with os.fdopen(source_descriptor, "rb") as reader, os.fdopen(
                destination_descriptor, "wb"
            ) as target:
                source_descriptor = -1
                destination_descriptor = -1
                while True:
                    chunk = reader.read(1024 * 1024)
                    if not chunk:
                        break
                    copied += len(chunk)
                    if copied > max_bytes:
                        raise IngestionError(
                            "ARTIFACT_TOO_LARGE", "delivery artifact exceeds its size limit"
                        )
                    digest.update(chunk)
                    target.write(chunk)
                target.flush()
                os.fsync(target.fileno())
        except FileNotFoundError:
            destination.unlink(missing_ok=True)
            raise IngestionError(
                "ARTIFACT_NOT_FOUND", "upload source is unavailable"
            ) from None
        except OSError:
            destination.unlink(missing_ok=True)
            raise IngestionError(
                "OBJECT_STORE_UNAVAILABLE", "object storage upload did not complete"
            ) from None
        except Exception:
            destination.unlink(missing_ok=True)
            raise
        finally:
            if source_descriptor >= 0:
                os.close(source_descriptor)
            if destination_descriptor >= 0:
                os.close(destination_descriptor)
        return digest.hexdigest(), copied

    def _hash_object(self, object_key: str, max_bytes: int) -> Tuple[str, int]:
        copied = 0
        digest = hashlib.sha256()
        with self._open_reader(object_key) as reader:
            while True:
                chunk = self._read_chunk(reader)
                if not chunk:
                    break
                if not isinstance(chunk, bytes):
                    raise IngestionError(
                        "OBJECT_STORE_UNAVAILABLE", "object storage read did not complete"
                    )
                copied += len(chunk)
                if copied > max_bytes:
                    raise IngestionError(
                        "ARTIFACT_TOO_LARGE", "delivery artifact exceeds its size limit"
                    )
                digest.update(chunk)
        return digest.hexdigest(), copied

    def _open_reader(self, object_key: str):
        try:
            return self._backend.open_reader(object_key)
        except BackendError as error:
            self._raise_backend(error)

    @staticmethod
    def _read_chunk(reader) -> bytes:
        try:
            return reader.read(1024 * 1024)
        except BackendError as error:
            ManagedObjectStore._raise_backend(error)
        except OSError:
            raise IngestionError(
                "OBJECT_STORE_UNAVAILABLE", "object storage read did not complete"
            ) from None

    def _validate_descriptor(self, descriptor: StoredObject, required_state: str) -> None:
        address = self._policy.parse(descriptor.object_key)
        if (
            address.job_id != descriptor.job_id
            or address.attempt_no != descriptor.attempt_no
            or address.state != descriptor.state
            or address.role != descriptor.role
            or descriptor.state != required_state
            or not valid_delivery_filename(descriptor.role, descriptor.original_filename)
            or not SHA256_RE.fullmatch(descriptor.sha256)
            or descriptor.byte_size < 0
            or descriptor.mime_type != ROLE_CONTRACTS[descriptor.role][2]
        ):
            raise IngestionError(
                "ARTIFACT_REFERENCE_INVALID", "stored object descriptor is invalid"
            )

    @staticmethod
    def _require_same_identity(left: StoredObject, right: StoredObject) -> None:
        if (
            left.job_id,
            left.attempt_no,
            left.role,
            left.sha256,
            left.byte_size,
            left.mime_type,
        ) != (
            right.job_id,
            right.attempt_no,
            right.role,
            right.sha256,
            right.byte_size,
            right.mime_type,
        ):
            raise IngestionError(
                "ARTIFACT_CONFLICT", "stored object identity conflicts"
            )

    @staticmethod
    def _raise_backend(error: BackendError) -> None:
        if error.kind == "not_found":
            raise IngestionError("ARTIFACT_NOT_FOUND", "stored object is unavailable") from None
        if error.kind == "conflict":
            raise IngestionError("ARTIFACT_CONFLICT", "object key already exists") from None
        if error.kind == "invalid_request":
            raise IngestionError("ARTIFACT_REFERENCE_INVALID", "object request is invalid") from None
        raise IngestionError(
            "OBJECT_STORE_UNAVAILABLE", "object storage operation did not complete"
        ) from None
