from contextlib import contextmanager
import base64
import ctypes
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path
import shutil
import sys
import time
from threading import Lock
from typing import Any, Dict, List
from urllib.parse import quote
import uuid

try:
    import msvcrt
except ImportError:
    msvcrt = None

from fastapi import FastAPI, File, Form, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles


APP_DIR = Path(__file__).resolve().parent
DATA_DIR = Path(os.environ.get("ROBOCLIENT_DATA_DIR", "C:/Users/Public/roboFiles"))
API_CONTRACT_VERSION = "2026.06.01.12"
SCHEMA_VERSION = "1.0.0"
DOCUMENTS_DIR_NAME = "Documents"
TEMP_OPEN_DIR_NAME = "TempOpen"
INDEX_HTML = "DocumentsIntake.html"
REGISTRY_FILE_NAME = "party-registry.json"
STORAGE_INSTANCE_FILE_NAME = "storage-instance.json"
PARTY_FILE_NAME = "party.json"
REGISTRY_LOCK_FILE_NAME = "party-registry.lock"
KEY_FILE_NAME = "robo-key.dpapi"
RECOVERY_KEY_FILE_NAME = "robo-recovery-key.json"
ENCRYPTION_MAGIC = b"ROBOAESGCM1\n"
NONCE_SIZE = 12
TAG_SIZE = 16
KEY_SIZE = 32
RECOVERY_SALT_SIZE = 16
RECOVERY_PBKDF2_ITERATIONS = 600_000
TEMP_OPEN_MAX_AGE_SECONDS = 60 * 60
REGISTRY_LOCK = Lock()
KEY_LOCK = Lock()
MIGRATION_LOCK = Lock()

app = FastAPI(title="Documents Intake API", version="1.0.0")
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.get("/")
def index() -> FileResponse:
    return FileResponse(
        APP_DIR / INDEX_HTML,
        headers={
            "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
            "Pragma": "no-cache",
            "Expires": "0",
        },
    )


@app.get("/health")
def health() -> dict:
    encrypt_existing_data_at_rest()
    return {
        "status": "ok",
        "apiContractVersion": API_CONTRACT_VERSION,
        "schemaVersion": SCHEMA_VERSION,
        "appDir": str(APP_DIR),
        "dataDir": str(DATA_DIR),
        "documentsDir": str(DATA_DIR / DOCUMENTS_DIR_NAME),
        "registryFile": str(get_registry_path()),
        "encryption": "AES-256-GCM",
        "recoveryKeyFile": str(get_recovery_key_path()),
        "keyFile": str(get_key_path()),
        "keyExists": get_key_path().exists(),
        "recoveryKeyExists": get_recovery_key_path().exists(),
        "storageInstanceId": ensure_storage_instance_id(),
        "schemaVersion": SCHEMA_VERSION,
    }


@app.get("/api/storage/status")
def storage_status() -> dict:
    return {
        "status": "ok",
        "dataDir": str(DATA_DIR),
        "keyFile": str(get_key_path()),
        "keyExists": get_key_path().exists(),
        "recoveryKeyFile": str(get_recovery_key_path()),
        "recoveryKeyExists": get_recovery_key_path().exists(),
        "storageInstanceId": ensure_storage_instance_id(),
        "tempOpenDir": str(DATA_DIR / TEMP_OPEN_DIR_NAME),
    }


@app.post("/api/storage/open-root")
def open_storage_root() -> JSONResponse:
    DATA_DIR.mkdir(parents=True, exist_ok=True)

    try:
        os.startfile(str(DATA_DIR))
    except OSError as exc:
        return JSONResponse(
            {
                "status": "open_failed",
                "message": str(exc),
                "dataDir": str(DATA_DIR),
            },
            status_code=500,
        )

    return JSONResponse(
        {
            "status": "opened",
            "dataDir": str(DATA_DIR),
        }
    )


@app.post("/api/recovery-key/export")
async def export_recovery_key(payload: Dict[str, Any]) -> JSONResponse:
    password = str(payload.get("password", ""))
    if not password:
        return JSONResponse(
            {
                "status": "missing_password",
                "message": "Recovery key export requires a password.",
            },
            status_code=400,
        )

    try:
        recovery = create_recovery_key_payload(get_data_key(), password)
    except MissingDataKey:
        return JSONResponse(
            {
                "status": "missing_data_key",
                "message": "Local DPAPI key is missing. Import an existing recovery key before exporting.",
            },
            status_code=409,
        )
    write_public_json(get_recovery_key_path(), recovery)

    return JSONResponse(
        {
            "status": "exported",
            "recoveryKeyFile": str(get_recovery_key_path()),
            "kdf": recovery["kdf"],
            "iterations": recovery["iterations"],
            "createdAt": recovery["createdAt"],
        }
    )


@app.post("/api/recovery-key/import")
async def import_recovery_key(payload: Dict[str, Any]) -> JSONResponse:
    password = str(payload.get("password", ""))
    overwrite = bool(payload.get("overwrite", False))

    if not password:
        return JSONResponse(
            {
                "status": "missing_password",
                "message": "Recovery key import requires a password.",
            },
            status_code=400,
        )

    key_path = get_key_path()
    if key_path.exists() and not overwrite:
        return JSONResponse(
            {
                "status": "key_exists",
                "message": "Local DPAPI key already exists. Set overwrite=true to replace it.",
                "keyFile": str(key_path),
            },
            status_code=409,
        )

    recovery = payload.get("recovery")
    if recovery is None:
        recovery = read_public_json(get_recovery_key_path(), default={})

    try:
        key = decrypt_recovery_key_payload(recovery, password)
    except (KeyError, TypeError, ValueError, RuntimeError):
        return JSONResponse(
            {
                "status": "invalid_recovery_key",
                "message": "Recovery key could not be decrypted with the supplied password.",
            },
            status_code=400,
        )

    save_dpapi_data_key(key)

    return JSONResponse(
        {
            "status": "imported",
            "keyFile": str(key_path),
            "recoveryKeyFile": str(get_recovery_key_path()),
        }
    )


@app.post("/api/party-registry")
async def save_party_registry(registry: Dict[str, Any]) -> JSONResponse:
    expected_revision = registry_revision(registry)

    try:
        saved_registry, saved = materialize_registry(registry, expected_revision=expected_revision)
    except StorageInstanceConflict as conflict:
        return JSONResponse(
            {
                "status": "storage_instance_conflict",
                "message": "Storage was reset or replaced. Reload before saving.",
                "expectedStorageInstanceId": conflict.current_storage_instance_id,
                "receivedStorageInstanceId": conflict.received_storage_instance_id,
                "registry": conflict.current_registry,
            },
            status_code=409,
        )
    except RegistryConflict as conflict:
        return JSONResponse(
            {
                "status": "conflict",
                "message": "Party registry has changed. Reload before saving.",
                "expectedRevision": conflict.current_revision,
                "receivedRevision": conflict.received_revision,
                "registry": conflict.current_registry,
            },
            status_code=409,
        )

    return JSONResponse(
        {
            "status": "saved",
            "registryFile": str(get_registry_path()),
            "registryRevision": saved_registry["registryRevision"],
            "registry": saved_registry,
            "parties": saved,
        }
    )


@app.get("/api/party-registry")
def load_party_registry() -> JSONResponse:
    encrypt_existing_data_at_rest()
    storage_instance_id = ensure_storage_instance_id()
    registry = read_json(get_registry_path(), default={})

    if not registry:
        registry = {
            "version": 1,
            "schemaVersion": SCHEMA_VERSION,
            "appContractVersion": API_CONTRACT_VERSION,
            "selectedPartyId": None,
            "parties": [],
            "circles": [],
            "relationships": [],
            "relationshipTypes": [],
            "registryRevision": 0,
        }
    else:
        registry["registryRevision"] = registry_revision(registry)

    stamp_registry_metadata(registry)
    registry["storageInstanceId"] = storage_instance_id
    return JSONResponse(registry)


@app.post("/api/documents")
async def upload_documents(
    files: List[UploadFile] = File(...),
    partyId: str = Form(default=""),
) -> JSONResponse:
    encrypt_existing_data_at_rest()
    documents_dir = DATA_DIR / get_documents_dir_name(partyId)

    if partyId and not documents_dir.exists():
        return JSONResponse(
            {
                "status": "missing_party_folder",
                "partyId": partyId,
                "message": "Party folder does not exist. Save the party registry before uploading documents.",
                "documentsDir": str(documents_dir),
            },
            status_code=409,
        )

    documents_dir.mkdir(parents=True, exist_ok=True)

    saved = []

    for upload in files:
        safe_name = sanitize_filename(upload.filename or "uploaded-file")
        target = unique_path(documents_dir / safe_name)
        size = await save_upload(upload, target)

        saved.append(
            {
                "originalName": upload.filename,
                "savedName": target.name,
                "size": size,
                "encrypted": True,
                "encryption": "AES-256-GCM",
                "encryptedSize": target.stat().st_size,
                "contentType": upload.content_type,
                "path": str(target),
                "uploadedAt": utc_now(),
            }
        )

    if partyId:
        update_document_references(partyId, saved)

    return JSONResponse(
        {
            "status": "saved",
            "partyId": partyId,
            "count": len(saved),
            "documentsDir": str(documents_dir),
            "files": saved,
        }
    )


@app.post("/api/documents/temp-open/cleanup")
def cleanup_temp_open_documents() -> JSONResponse:
    removed = cleanup_temp_open_files(force=True)

    return JSONResponse(
        {
            "status": "cleaned",
            "temporaryDirectory": str(DATA_DIR / TEMP_OPEN_DIR_NAME),
            "removedFiles": removed,
        }
    )


@app.get("/api/documents")
def list_all_documents() -> JSONResponse:
    try:
        encrypt_existing_data_at_rest()
        registry = read_json(get_registry_path(), default={})
    except MissingDataKey:
        return JSONResponse(
            {
                "status": "missing_data_key",
                "message": "Local DPAPI key is missing. Import recovery key before browsing encrypted documents.",
            },
            status_code=409,
        )

    parties = registry.get("parties", [])
    known_party_ids = {
        str(party.get("partyId", "")).strip()
        for party in parties
        if str(party.get("partyId", "")).strip()
    }
    folders = []

    for party in parties:
        party_id = str(party.get("partyId", "")).strip()
        if not party_id:
            continue

        files = documents_for_party(party_id)
        folders.append(
            {
                "partyId": party_id,
                "displayName": party.get("displayName") or party_id,
                "partyType": party.get("partyType") or "",
                "documentsDir": str(DATA_DIR / get_documents_dir_name(party_id)),
                "fileCount": len(files),
                "files": files,
            }
        )

    for path in sorted(DATA_DIR.glob(f"{DOCUMENTS_DIR_NAME}_*"), key=lambda item: item.name.lower()):
        if not path.is_dir():
            continue

        party_id = path.name[len(f"{DOCUMENTS_DIR_NAME}_"):]
        if party_id in known_party_ids:
            continue

        files = documents_for_party(party_id)
        if not files:
            continue

        folders.append(
            {
                "partyId": party_id,
                "displayName": party_id,
                "partyType": "unregistered",
                "documentsDir": str(path),
                "fileCount": len(files),
                "files": files,
            }
        )

    return JSONResponse(
        {
            "status": "loaded",
            "dataDir": str(DATA_DIR),
            "folders": folders,
            "folderCount": len(folders),
            "fileCount": sum(folder["fileCount"] for folder in folders),
        }
    )


@app.get("/api/documents/{party_id}")
def list_documents(party_id: str) -> JSONResponse:
    try:
        encrypt_existing_data_at_rest()
    except MissingDataKey:
        return JSONResponse(
            {
                "status": "missing_data_key",
                "message": "Local DPAPI key is missing. Import recovery key before browsing encrypted documents.",
            },
            status_code=409,
        )
    documents_dir = DATA_DIR / get_documents_dir_name(party_id)

    return JSONResponse(
        {
            "status": "loaded",
            "partyId": party_id,
            "documentsDir": str(documents_dir),
            "files": documents_for_party(party_id),
        }
    )


@app.get("/api/documents/{party_id}/files/{filename}")
def open_document(party_id: str, filename: str, download: bool = False) -> Response:
    encrypt_existing_data_at_rest()
    safe_name = sanitize_filename(filename)
    path = DATA_DIR / get_documents_dir_name(party_id) / safe_name

    if not path.exists() or not path.is_file():
        return JSONResponse(
            {
                "status": "not_found",
                "partyId": party_id,
                "filename": safe_name,
            },
            status_code=404,
        )

    raw = path.read_bytes()
    content = decrypt_bytes(raw) if raw.startswith(ENCRYPTION_MAGIC) else raw
    reference = document_reference_for_file(party_id, safe_name)
    original_name = reference.get("originalName") or safe_name
    content_type = reference.get("contentType") or "application/octet-stream"
    disposition = "attachment" if download else "inline"

    return Response(
        content=content,
        media_type=content_type,
        headers={
            "Content-Disposition": content_disposition(disposition, original_name),
            "X-Robo-Encrypted-At-Rest": "AES-256-GCM",
        },
    )


@app.post("/api/documents/{party_id}/files/{filename}/open-native")
def open_document_native(party_id: str, filename: str) -> JSONResponse:
    encrypt_existing_data_at_rest()
    cleanup_temp_open_files()
    safe_name = sanitize_filename(filename)
    source = DATA_DIR / get_documents_dir_name(party_id) / safe_name

    if not source.exists() or not source.is_file():
        return JSONResponse(
            {
                "status": "not_found",
                "partyId": party_id,
                "filename": safe_name,
            },
            status_code=404,
        )

    reference = document_reference_for_file(party_id, safe_name)
    original_name = sanitize_filename(reference.get("originalName") or safe_name)
    target_dir = get_temp_open_dir() / sanitize_path_part(party_id)
    target_dir.mkdir(parents=True, exist_ok=True)
    target = unique_path(target_dir / original_name)

    raw = source.read_bytes()
    content = decrypt_bytes(raw) if raw.startswith(ENCRYPTION_MAGIC) else raw
    target.write_bytes(content)
    os.startfile(str(target))

    return JSONResponse(
        {
            "status": "opened",
            "partyId": party_id,
            "sourceFile": safe_name,
            "temporaryFile": str(target),
            "cleanupAfterSeconds": TEMP_OPEN_MAX_AGE_SECONDS,
        }
    )


@app.delete("/api/documents/{party_id}/files/{filename}")
def delete_document(party_id: str, filename: str) -> JSONResponse:
    encrypt_existing_data_at_rest()
    safe_name = sanitize_filename(filename)
    path = DATA_DIR / get_documents_dir_name(party_id) / safe_name

    if not path.exists() or not path.is_file():
        return JSONResponse(
            {
                "status": "not_found",
                "partyId": party_id,
                "filename": safe_name,
            },
            status_code=404,
        )

    path.unlink()
    removed_reference = remove_document_reference(party_id, safe_name)

    return JSONResponse(
        {
            "status": "deleted",
            "partyId": party_id,
            "filename": safe_name,
            "removedReference": removed_reference,
        }
    )


class RegistryConflict(Exception):
    def __init__(
        self,
        current_revision: int,
        received_revision: int,
        current_registry: Dict[str, Any],
    ) -> None:
        self.current_revision = current_revision
        self.received_revision = received_revision
        self.current_registry = current_registry


class StorageInstanceConflict(Exception):
    def __init__(
        self,
        current_storage_instance_id: str,
        received_storage_instance_id: str,
        current_registry: Dict[str, Any],
    ) -> None:
        self.current_storage_instance_id = current_storage_instance_id
        self.received_storage_instance_id = received_storage_instance_id
        self.current_registry = current_registry


class MissingDataKey(Exception):
    pass


def materialize_registry(
    registry: Dict[str, Any],
    expected_revision: int | None = None,
) -> tuple[Dict[str, Any], List[Dict[str, str]]]:
    encrypt_existing_data_at_rest()
    with REGISTRY_LOCK:
        with registry_file_lock():
            return materialize_registry_locked(registry, expected_revision)


def materialize_registry_locked(
    registry: Dict[str, Any],
    expected_revision: int | None = None,
) -> tuple[Dict[str, Any], List[Dict[str, str]]]:
    storage_instance_id = ensure_storage_instance_id()
    current_registry = read_json(get_registry_path(), default={})
    current_revision = registry_revision(current_registry)
    received_storage_instance_id = str(registry.get("storageInstanceId", "")).strip()

    if received_storage_instance_id and received_storage_instance_id != storage_instance_id:
        current_registry["registryRevision"] = current_revision
        current_registry["storageInstanceId"] = storage_instance_id
        raise StorageInstanceConflict(
            storage_instance_id,
            received_storage_instance_id,
            current_registry,
        )

    if expected_revision is not None and expected_revision != current_revision:
        current_registry["registryRevision"] = current_revision
        current_registry["storageInstanceId"] = storage_instance_id
        raise RegistryConflict(current_revision, expected_revision, current_registry)

    registry_copy = dict(registry)
    preserve_document_references(registry_copy)
    stamp_registry_metadata(registry_copy)
    registry_copy["storageInstanceId"] = storage_instance_id
    registry_copy["registryRevision"] = current_revision + 1
    registry_copy["materializedAt"] = utc_now()
    write_json(get_registry_path(), registry_copy)

    saved_parties = []
    for party in registry_copy.get("parties", []):
        party_id = str(party.get("partyId", "")).strip()
        if not party_id:
            continue

        documents_dir = DATA_DIR / get_documents_dir_name(party_id)
        documents_dir.mkdir(parents=True, exist_ok=True)
        party_file = documents_dir / PARTY_FILE_NAME
        write_json(
            party_file,
            stamp_party_file_payload({
                "party": party,
                "materializedAt": utc_now(),
                "registryFile": str(get_registry_path()),
                "documentsDir": str(documents_dir),
            }),
        )
        saved_parties.append(
            {
                "partyId": party_id,
                "documentsDir": str(documents_dir),
                "partyFile": str(party_file),
            }
        )

    return registry_copy, saved_parties


def stamp_registry_metadata(registry: Dict[str, Any]) -> Dict[str, Any]:
    registry["schemaVersion"] = SCHEMA_VERSION
    registry["appContractVersion"] = API_CONTRACT_VERSION
    return registry


def stamp_party_file_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
    payload["schemaVersion"] = SCHEMA_VERSION
    payload["appContractVersion"] = API_CONTRACT_VERSION
    return payload


def preserve_document_references(registry: Dict[str, Any]) -> None:
    existing = read_json(get_registry_path(), default={})
    existing_by_party = {
        party.get("partyId"): party
        for party in existing.get("parties", [])
        if party.get("partyId")
    }

    for party in registry.get("parties", []):
        party_id = party.get("partyId")
        existing_party = existing_by_party.get(party_id)
        if not existing_party:
            continue

        existing_refs = existing_party.get("documents", {}).get("references", [])
        if not existing_refs:
            continue

        documents = party.setdefault("documents", {})
        if documents.pop("_referencesAuthoritative", False):
            continue

        incoming_refs = documents.get("references", [])
        if not incoming_refs:
            documents["references"] = existing_refs


def update_document_references(party_id: str, saved_files: List[Dict[str, Any]]) -> None:
    with REGISTRY_LOCK:
        with registry_file_lock():
            registry = read_json(get_registry_path(), default={})
            parties = registry.get("parties", [])
            party = next((item for item in parties if item.get("partyId") == party_id), None)

            if party is None:
                party = {"partyId": party_id, "documents": {"references": []}}
                parties.append(party)
                registry["parties"] = parties

            documents = party.setdefault("documents", {})
            references = documents.setdefault("references", [])

            for saved in saved_files:
                references.append(
                    {
                        "originalName": saved["originalName"],
                        "savedName": saved["savedName"],
                        "size": saved["size"],
                        "contentType": saved["contentType"],
                        "path": saved["path"],
                        "uploadedAt": saved["uploadedAt"],
                    }
                )

            documents["collectorStatus"] = "uploaded"
            party["updatedAt"] = utc_now()
            materialize_registry_locked(registry)


def documents_for_party(party_id: str) -> List[Dict[str, Any]]:
    documents_dir = DATA_DIR / get_documents_dir_name(party_id)
    registry = read_json(get_registry_path(), default={})
    party = next(
        (item for item in registry.get("parties", []) if item.get("partyId") == party_id),
        None,
    )
    references = party.get("documents", {}).get("references", []) if party else []
    by_saved_name = {
        reference.get("savedName"): reference
        for reference in references
        if reference.get("savedName")
    }

    files = []
    if not documents_dir.exists():
        return files

    for path in sorted(documents_dir.iterdir(), key=lambda item: item.name.lower()):
        if not path.is_file() or should_skip_document_listing(path):
            continue

        reference = by_saved_name.get(path.name, {})
        raw = path.read_bytes()
        encrypted = raw.startswith(ENCRYPTION_MAGIC)
        files.append(
            {
                "savedName": path.name,
                "originalName": reference.get("originalName") or path.name,
                "size": reference.get("size"),
                "encryptedSize": path.stat().st_size,
                "contentType": reference.get("contentType") or "application/octet-stream",
                "uploadedAt": reference.get("uploadedAt"),
                "encrypted": encrypted,
                "encryption": "AES-256-GCM" if encrypted else "none",
                "openUrl": f"/api/documents/{quote(party_id)}/files/{quote(path.name)}",
                "downloadUrl": f"/api/documents/{quote(party_id)}/files/{quote(path.name)}?download=true",
            }
        )

    return files


def document_reference_for_file(party_id: str, saved_name: str) -> Dict[str, Any]:
    registry = read_json(get_registry_path(), default={})
    party = next(
        (item for item in registry.get("parties", []) if item.get("partyId") == party_id),
        None,
    )
    if not party:
        return {}

    references = party.get("documents", {}).get("references", [])
    return next(
        (reference for reference in references if reference.get("savedName") == saved_name),
        {},
    )


def remove_document_reference(party_id: str, saved_name: str) -> bool:
    with REGISTRY_LOCK:
        with registry_file_lock():
            registry = read_json(get_registry_path(), default={})
            party = next(
                (item for item in registry.get("parties", []) if item.get("partyId") == party_id),
                None,
            )
            if not party:
                return False

            documents = party.get("documents", {})
            references = documents.get("references", [])
            remaining = [
                reference
                for reference in references
                if reference.get("savedName") != saved_name
            ]
            if len(remaining) == len(references):
                return False

            documents["references"] = remaining
            documents["_referencesAuthoritative"] = True
            party["updatedAt"] = utc_now()
            materialize_registry_locked(registry)
            return True


def should_skip_document_listing(path: Path) -> bool:
    name = path.name
    return (
        name == PARTY_FILE_NAME
        or name.endswith(".tmp")
        or name.endswith(".lock")
        or name.endswith(".dpapi")
        or name == RECOVERY_KEY_FILE_NAME
        or name == REGISTRY_LOCK_FILE_NAME
    )


def content_disposition(disposition: str, filename: str) -> str:
    safe_ascii = sanitize_filename(filename).encode("ascii", "ignore").decode("ascii") or "document"
    return f"{disposition}; filename=\"{safe_ascii}\"; filename*=UTF-8''{quote(filename)}"


def get_temp_open_dir() -> Path:
    temp_dir = DATA_DIR / TEMP_OPEN_DIR_NAME
    temp_dir.mkdir(parents=True, exist_ok=True)
    return temp_dir


def cleanup_temp_open_files(force: bool = False) -> int:
    temp_dir = DATA_DIR / TEMP_OPEN_DIR_NAME
    if not temp_dir.exists():
        return 0

    cutoff = time.time() - TEMP_OPEN_MAX_AGE_SECONDS
    removed = 0
    for path in temp_dir.rglob("*"):
        if path.is_file() and (force or path.stat().st_mtime < cutoff):
            path.unlink(missing_ok=True)
            removed += 1

    for path in sorted(temp_dir.rglob("*"), reverse=True):
        if path.is_dir():
            try:
                path.rmdir()
            except OSError:
                pass

    return removed


async def save_upload(upload: UploadFile, target: Path) -> int:
    size = 0
    target.parent.mkdir(parents=True, exist_ok=True)
    temp_path = target.with_name(f"{target.name}.tmp")
    key = get_data_key()
    nonce = os.urandom(NONCE_SIZE)
    encryptor = AesGcmEncryptor(key, nonce)

    with temp_path.open("wb") as output:
        output.write(ENCRYPTION_MAGIC)
        output.write(nonce)
        output.write(b"\x00" * TAG_SIZE)

        while True:
            chunk = await upload.read(1024 * 1024)

            if not chunk:
                break

            size += len(chunk)
            output.write(encryptor.update(chunk))

        output.write(encryptor.finalize())
        tag = encryptor.tag()
        output.seek(len(ENCRYPTION_MAGIC) + NONCE_SIZE)
        output.write(tag)

    await upload.close()
    temp_path.replace(target)
    return size


def sanitize_filename(filename: str) -> str:
    name = Path(filename).name.strip()

    if not name:
        return "uploaded-file"

    invalid = '<>:"/\\|?*'
    cleaned = "".join("_" if char in invalid or ord(char) < 32 else char for char in name)
    cleaned = cleaned.rstrip(" .")
    return cleaned or "uploaded-file"


def get_documents_dir_name(party_id: str) -> str:
    safe_party_id = sanitize_path_part(party_id)

    if not safe_party_id:
        return DOCUMENTS_DIR_NAME

    return f"{DOCUMENTS_DIR_NAME}_{safe_party_id}"


def get_registry_path() -> Path:
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    return DATA_DIR / REGISTRY_FILE_NAME


def get_storage_instance_path() -> Path:
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    return DATA_DIR / STORAGE_INSTANCE_FILE_NAME


def ensure_storage_instance_id() -> str:
    path = get_storage_instance_path()
    payload = read_public_json(path, default={})
    storage_instance_id = str(payload.get("storageInstanceId", "")).strip()

    if storage_instance_id:
        return storage_instance_id

    storage_instance_id = str(uuid.uuid4())
    write_public_json(
        path,
        {
            "storageInstanceId": storage_instance_id,
            "createdAt": utc_now(),
        },
    )
    return storage_instance_id


@contextmanager
def registry_file_lock():
    DATA_DIR.mkdir(parents=True, exist_ok=True)

    if msvcrt is None:
        yield
        return

    lock_path = DATA_DIR / REGISTRY_LOCK_FILE_NAME
    with lock_path.open("a+b") as handle:
        msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
        try:
            yield
        finally:
            handle.seek(0)
            msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)


def write_json(path: Path, data: Dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    temp_path = path.with_name(f"{path.name}.tmp")
    plaintext = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
    temp_path.write_bytes(encrypt_bytes(plaintext))
    temp_path.replace(path)


def read_json(path: Path, default: Dict[str, Any]) -> Dict[str, Any]:
    if not path.exists():
        return default

    try:
        raw = path.read_bytes()
        if raw.startswith(ENCRYPTION_MAGIC):
            raw = decrypt_bytes(raw)
        return json.loads(raw.decode("utf-8"))
    except (OSError, ValueError, UnicodeDecodeError, json.JSONDecodeError):
        return default


class AesGcmEncryptor:
    def __init__(self, key: bytes, nonce: bytes) -> None:
        self.ctx = openssl().EVP_CIPHER_CTX_new()
        if not self.ctx:
            raise RuntimeError("Could not allocate AES-GCM context.")

        try:
            ensure_crypto(openssl().EVP_EncryptInit_ex(self.ctx, openssl().EVP_aes_256_gcm(), None, None, None))
            ensure_crypto(
                openssl().EVP_CIPHER_CTX_ctrl(self.ctx, EVP_CTRL_GCM_SET_IVLEN, len(nonce), None)
            )
            ensure_crypto(
                openssl().EVP_EncryptInit_ex(
                    self.ctx,
                    None,
                    None,
                    ctypes.c_char_p(key),
                    ctypes.c_char_p(nonce),
                )
            )
        except Exception:
            openssl().EVP_CIPHER_CTX_free(self.ctx)
            raise

    def update(self, chunk: bytes) -> bytes:
        output = ctypes.create_string_buffer(len(chunk) + 16)
        out_len = ctypes.c_int(0)
        ensure_crypto(
            openssl().EVP_EncryptUpdate(
                self.ctx,
                output,
                ctypes.byref(out_len),
                ctypes.c_char_p(chunk),
                len(chunk),
            )
        )
        return output.raw[: out_len.value]

    def finalize(self) -> bytes:
        output = ctypes.create_string_buffer(16)
        out_len = ctypes.c_int(0)
        ensure_crypto(openssl().EVP_EncryptFinal_ex(self.ctx, output, ctypes.byref(out_len)))
        return output.raw[: out_len.value]

    def tag(self) -> bytes:
        tag = ctypes.create_string_buffer(TAG_SIZE)
        ensure_crypto(
            openssl().EVP_CIPHER_CTX_ctrl(self.ctx, EVP_CTRL_GCM_GET_TAG, TAG_SIZE, tag)
        )
        openssl().EVP_CIPHER_CTX_free(self.ctx)
        self.ctx = None
        return tag.raw


class AesGcmDecryptor:
    def __init__(self, key: bytes, nonce: bytes, tag: bytes) -> None:
        self.ctx = openssl().EVP_CIPHER_CTX_new()
        if not self.ctx:
            raise RuntimeError("Could not allocate AES-GCM context.")

        try:
            ensure_crypto(openssl().EVP_DecryptInit_ex(self.ctx, openssl().EVP_aes_256_gcm(), None, None, None))
            ensure_crypto(
                openssl().EVP_CIPHER_CTX_ctrl(self.ctx, EVP_CTRL_GCM_SET_IVLEN, len(nonce), None)
            )
            ensure_crypto(
                openssl().EVP_DecryptInit_ex(
                    self.ctx,
                    None,
                    None,
                    ctypes.c_char_p(key),
                    ctypes.c_char_p(nonce),
                )
            )
            self.tag = tag
        except Exception:
            openssl().EVP_CIPHER_CTX_free(self.ctx)
            raise

    def update(self, chunk: bytes) -> bytes:
        output = ctypes.create_string_buffer(len(chunk) + 16)
        out_len = ctypes.c_int(0)
        ensure_crypto(
            openssl().EVP_DecryptUpdate(
                self.ctx,
                output,
                ctypes.byref(out_len),
                ctypes.c_char_p(chunk),
                len(chunk),
            )
        )
        return output.raw[: out_len.value]

    def finalize(self) -> bytes:
        tag_buffer = ctypes.create_string_buffer(self.tag)
        ensure_crypto(
            openssl().EVP_CIPHER_CTX_ctrl(self.ctx, EVP_CTRL_GCM_SET_TAG, TAG_SIZE, tag_buffer)
        )
        output = ctypes.create_string_buffer(16)
        out_len = ctypes.c_int(0)
        result = openssl().EVP_DecryptFinal_ex(self.ctx, output, ctypes.byref(out_len))
        openssl().EVP_CIPHER_CTX_free(self.ctx)
        self.ctx = None
        if result <= 0:
            raise ValueError("AES-GCM authentication failed.")
        return output.raw[: out_len.value]


EVP_CTRL_GCM_SET_IVLEN = 0x09
EVP_CTRL_GCM_GET_TAG = 0x10
EVP_CTRL_GCM_SET_TAG = 0x11
_OPENSSL = None


def openssl():
    global _OPENSSL
    if _OPENSSL is not None:
        return _OPENSSL

    crypto_path = Path(sys.executable).resolve().parent / "libcrypto-3.dll"
    lib = ctypes.CDLL(str(crypto_path))
    lib.EVP_CIPHER_CTX_new.restype = ctypes.c_void_p
    lib.EVP_CIPHER_CTX_free.argtypes = [ctypes.c_void_p]
    lib.EVP_aes_256_gcm.restype = ctypes.c_void_p
    lib.EVP_EncryptInit_ex.argtypes = [
        ctypes.c_void_p,
        ctypes.c_void_p,
        ctypes.c_void_p,
        ctypes.c_char_p,
        ctypes.c_char_p,
    ]
    lib.EVP_EncryptInit_ex.restype = ctypes.c_int
    lib.EVP_EncryptUpdate.argtypes = [
        ctypes.c_void_p,
        ctypes.c_void_p,
        ctypes.POINTER(ctypes.c_int),
        ctypes.c_char_p,
        ctypes.c_int,
    ]
    lib.EVP_EncryptUpdate.restype = ctypes.c_int
    lib.EVP_EncryptFinal_ex.argtypes = [
        ctypes.c_void_p,
        ctypes.c_void_p,
        ctypes.POINTER(ctypes.c_int),
    ]
    lib.EVP_EncryptFinal_ex.restype = ctypes.c_int
    lib.EVP_DecryptInit_ex.argtypes = lib.EVP_EncryptInit_ex.argtypes
    lib.EVP_DecryptInit_ex.restype = ctypes.c_int
    lib.EVP_DecryptUpdate.argtypes = lib.EVP_EncryptUpdate.argtypes
    lib.EVP_DecryptUpdate.restype = ctypes.c_int
    lib.EVP_DecryptFinal_ex.argtypes = lib.EVP_EncryptFinal_ex.argtypes
    lib.EVP_DecryptFinal_ex.restype = ctypes.c_int
    lib.EVP_CIPHER_CTX_ctrl.argtypes = [
        ctypes.c_void_p,
        ctypes.c_int,
        ctypes.c_int,
        ctypes.c_void_p,
    ]
    lib.EVP_CIPHER_CTX_ctrl.restype = ctypes.c_int
    _OPENSSL = lib
    return lib


def ensure_crypto(result: int) -> None:
    if result <= 0:
        raise RuntimeError("OpenSSL AES-GCM operation failed.")


def encrypt_bytes(plaintext: bytes) -> bytes:
    nonce = os.urandom(NONCE_SIZE)
    encryptor = AesGcmEncryptor(get_data_key(), nonce)
    ciphertext = encryptor.update(plaintext) + encryptor.finalize()
    return ENCRYPTION_MAGIC + nonce + encryptor.tag() + ciphertext


def decrypt_bytes(payload: bytes) -> bytes:
    if not payload.startswith(ENCRYPTION_MAGIC):
        return payload

    header_size = len(ENCRYPTION_MAGIC) + NONCE_SIZE + TAG_SIZE
    if len(payload) < header_size:
        raise ValueError("Encrypted payload is too short.")

    nonce_start = len(ENCRYPTION_MAGIC)
    tag_start = nonce_start + NONCE_SIZE
    ciphertext_start = tag_start + TAG_SIZE
    nonce = payload[nonce_start:tag_start]
    tag = payload[tag_start:ciphertext_start]
    ciphertext = payload[ciphertext_start:]
    decryptor = AesGcmDecryptor(get_data_key(), nonce, tag)
    return decryptor.update(ciphertext) + decryptor.finalize()


def get_data_key() -> bytes:
    with KEY_LOCK:
        key_path = get_key_path()
        if key_path.exists():
            key = dpapi_unprotect(key_path.read_bytes())
            if len(key) != KEY_SIZE:
                raise ValueError("Invalid Robo data key length.")
            return key

        if encrypted_protected_data_exists():
            raise MissingDataKey("Local DPAPI key is missing for existing encrypted data.")

        key = os.urandom(KEY_SIZE)
        save_dpapi_data_key(key)
        return key


def encrypted_protected_data_exists() -> bool:
    if not DATA_DIR.exists():
        return False

    for path in DATA_DIR.rglob("*"):
        if not path.is_file() or should_skip_encryption_migration(path):
            continue
        try:
            if path.read_bytes().startswith(ENCRYPTION_MAGIC):
                return True
        except OSError:
            continue
    return False


def save_dpapi_data_key(key: bytes) -> None:
    if len(key) != KEY_SIZE:
        raise ValueError("Invalid Robo data key length.")

    key_path = get_key_path()
    temp_path = key_path.with_name(f"{key_path.name}.tmp")
    temp_path.write_bytes(dpapi_protect(key))
    temp_path.replace(key_path)


def get_key_path() -> Path:
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    return DATA_DIR / KEY_FILE_NAME


def get_recovery_key_path() -> Path:
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    return DATA_DIR / RECOVERY_KEY_FILE_NAME


def create_recovery_key_payload(data_key: bytes, password: str) -> Dict[str, Any]:
    salt = os.urandom(RECOVERY_SALT_SIZE)
    wrapping_key = derive_recovery_wrapping_key(password, salt, RECOVERY_PBKDF2_ITERATIONS)
    encrypted_key = encrypt_bytes_with_key(data_key, wrapping_key)

    return {
        "version": 1,
        "type": "RoboRecoveryKey",
        "algorithm": "AES-256-GCM",
        "kdf": "PBKDF2-HMAC-SHA256",
        "iterations": RECOVERY_PBKDF2_ITERATIONS,
        "salt": base64.b64encode(salt).decode("ascii"),
        "encryptedDataKey": base64.b64encode(encrypted_key).decode("ascii"),
        "createdAt": utc_now(),
    }


def decrypt_recovery_key_payload(recovery: Dict[str, Any], password: str) -> bytes:
    if recovery.get("type") != "RoboRecoveryKey":
        raise ValueError("Invalid recovery key type.")

    iterations = int(recovery["iterations"])
    salt = base64.b64decode(recovery["salt"])
    encrypted_key = base64.b64decode(recovery["encryptedDataKey"])
    wrapping_key = derive_recovery_wrapping_key(password, salt, iterations)
    data_key = decrypt_bytes_with_key(encrypted_key, wrapping_key)

    if len(data_key) != KEY_SIZE:
        raise ValueError("Invalid recovered data key length.")

    return data_key


def derive_recovery_wrapping_key(password: str, salt: bytes, iterations: int) -> bytes:
    return hashlib.pbkdf2_hmac(
        "sha256",
        password.encode("utf-8"),
        salt,
        iterations,
        dklen=KEY_SIZE,
    )


def encrypt_bytes_with_key(plaintext: bytes, key: bytes) -> bytes:
    nonce = os.urandom(NONCE_SIZE)
    encryptor = AesGcmEncryptor(key, nonce)
    ciphertext = encryptor.update(plaintext) + encryptor.finalize()
    return ENCRYPTION_MAGIC + nonce + encryptor.tag() + ciphertext


def decrypt_bytes_with_key(payload: bytes, key: bytes) -> bytes:
    if not payload.startswith(ENCRYPTION_MAGIC):
        raise ValueError("Recovery payload is not encrypted.")

    header_size = len(ENCRYPTION_MAGIC) + NONCE_SIZE + TAG_SIZE
    if len(payload) < header_size:
        raise ValueError("Encrypted payload is too short.")

    nonce_start = len(ENCRYPTION_MAGIC)
    tag_start = nonce_start + NONCE_SIZE
    ciphertext_start = tag_start + TAG_SIZE
    nonce = payload[nonce_start:tag_start]
    tag = payload[tag_start:ciphertext_start]
    ciphertext = payload[ciphertext_start:]
    decryptor = AesGcmDecryptor(key, nonce, tag)
    return decryptor.update(ciphertext) + decryptor.finalize()


def write_public_json(path: Path, data: Dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    temp_path = path.with_name(f"{path.name}.tmp")
    temp_path.write_text(
        json.dumps(data, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    temp_path.replace(path)


def read_public_json(path: Path, default: Dict[str, Any]) -> Dict[str, Any]:
    if not path.exists():
        return default

    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except (OSError, UnicodeDecodeError, json.JSONDecodeError):
        return default


def encrypt_existing_data_at_rest() -> None:
    with MIGRATION_LOCK:
        if not DATA_DIR.exists():
            return

        for path in DATA_DIR.rglob("*"):
            if not path.is_file() or should_skip_encryption_migration(path):
                continue

            raw = path.read_bytes()
            if raw.startswith(ENCRYPTION_MAGIC):
                continue

            temp_path = path.with_name(f"{path.name}.tmp")
            temp_path.write_bytes(encrypt_bytes(raw))
            temp_path.replace(path)


def should_skip_encryption_migration(path: Path) -> bool:
    name = path.name
    return (
        name == KEY_FILE_NAME
        or name == RECOVERY_KEY_FILE_NAME
        or name == STORAGE_INSTANCE_FILE_NAME
        or name == REGISTRY_LOCK_FILE_NAME
        or TEMP_OPEN_DIR_NAME in path.parts
        or name.endswith(".tmp")
        or name.endswith(".dpapi")
        or name.endswith(".lock")
    )


class DataBlob(ctypes.Structure):
    _fields_ = [
        ("cbData", ctypes.c_uint32),
        ("pbData", ctypes.POINTER(ctypes.c_ubyte)),
    ]


def dpapi_protect(data: bytes) -> bytes:
    return dpapi_call("CryptProtectData", data)


def dpapi_unprotect(data: bytes) -> bytes:
    return dpapi_call("CryptUnprotectData", data)


def dpapi_call(function_name: str, data: bytes) -> bytes:
    crypt32 = ctypes.windll.crypt32
    kernel32 = ctypes.windll.kernel32
    input_buffer = ctypes.create_string_buffer(data)
    input_blob = DataBlob(len(data), ctypes.cast(input_buffer, ctypes.POINTER(ctypes.c_ubyte)))
    output_blob = DataBlob()

    function = getattr(crypt32, function_name)
    function.argtypes = [
        ctypes.POINTER(DataBlob),
        ctypes.c_void_p,
        ctypes.c_void_p,
        ctypes.c_void_p,
        ctypes.c_void_p,
        ctypes.c_uint32,
        ctypes.POINTER(DataBlob),
    ]
    function.restype = ctypes.c_bool
    kernel32.LocalFree.argtypes = [ctypes.c_void_p]
    kernel32.LocalFree.restype = ctypes.c_void_p
    result = function(
        ctypes.byref(input_blob),
        None,
        None,
        None,
        None,
        0,
        ctypes.byref(output_blob),
    )
    if not result:
        raise ctypes.WinError()

    try:
        return ctypes.string_at(output_blob.pbData, output_blob.cbData)
    finally:
        kernel32.LocalFree(ctypes.cast(output_blob.pbData, ctypes.c_void_p))


def registry_revision(registry: Dict[str, Any]) -> int:
    try:
        return int(registry.get("registryRevision", 0))
    except (TypeError, ValueError):
        return 0


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat()


def sanitize_path_part(value: str) -> str:
    name = (value or "").strip()

    if not name:
        return ""

    invalid = '<>:"/\\|?*'
    cleaned = "".join("_" if char in invalid or ord(char) < 32 else char for char in name)
    cleaned = cleaned.strip().strip(".")
    return cleaned[:120]


def unique_path(path: Path) -> Path:
    if not path.exists():
        return path

    stem = path.stem
    suffix = path.suffix

    index = 1
    while True:
        candidate = path.with_name(f"{stem}_{index}{suffix}")

        if not candidate.exists():
            return candidate

        index += 1


app.mount("/static", StaticFiles(directory=APP_DIR), name="static")

