import asyncio
import copy
import hashlib
import itertools
import shutil
import stat
import sys
import time
from pathlib import Path
from typing import Optional, Tuple, Any, List, Type, Callable

import aiohttp
import safe_radix32
from clarabot_crypto.context.account_signature import ACCOUNT_SIGNATURE_CRYPTO_CONTEXT, AccountSignatureConfigLoadArgs
from clarabot_crypto.context.nano import NANO_CRYPTO_CONTEXT, NanoConfigDumpArgs
from clarabot_crypto.context.nano_adhoc import NANO_ADHOC_CRYPTO_CONTEXT, NanoAdhocConfigDumpArgs
from clarabot_crypto.context.nano_own import NANO_OWN_CRYPTO_CONTEXT, NanoOwnConfigDumpArgs
from clarabot_crypto.keyring.account_base import AbstractSelfAccountKeyring, AbstractPeerAccountKeyring
from clarabot_crypto.keyring.adhoc import AdhocKeyring
from clarabot_crypto.keyring.private import PRIVATE_KEYRING_VERSION_MAP
from clarabot_crypto.keyring.room_base import AbstractRoomKeyring
from clarabot_nano_common.common.protocol.errors import (
    NANO_REQUEST_ERROR_MUTATION_ID,
    NANO_REQUEST_ERROR_PAYLOAD_ROOM_INVALID,
    NANO_REQUEST_ERROR_ADMIN_SESSION,
)
from clarabot_nano_common.common.protocol.request import NanoBaseRequest
from clarabot_nano_common.utility.path.root import get_root_paths
from clarabot_nano_common.utility.serialize import msgpack_dumps, msgpack_loads
from clarabot_testing.client.gql_ws import BaseGraphQlTransport
from clarabot_testing.client.tester import AbstractClientTestingMixin

from clarabot_nano_tests.testing.client.gql import (
    ref_mutation_create_new_id,
    ref_query_privy_rooms,
    ref_mutate_privy_setup,
    SETUP_PRIVY_RESULT,
)
from clarabot_nano_tests.testing.client.gql_ws import GraphQlWebSocketClient
from clarabot_nano_tests.testing.client.stream import TestingNanoResponseStreamProcessor
from clarabot_nano_tests.testing.ls import merge_listing_responses
from clarabot_nano_tests.testing.server.manager import DevServerManager


class GraphQLClientManager(AbstractClientTestingMixin):
    def __init__(self, server_manager: DevServerManager):
        AbstractClientTestingMixin.__init__(self)
        self.server_manager = server_manager
        self._mutation_ids = dict()

    # client overrides
    async def wsc(self, session: aiohttp.ClientSession) -> GraphQlWebSocketClient:
        return await self.server_manager.websocket(session)

    def _get_server_hostname(self):
        return self.server_manager.server_frontend_domain

    def url_for(self, path):
        return self.server_manager.url_for(path)

    def _server_post_auto_csrf(self, s: aiohttp.ClientSession, url: str, *args, **kwargs):
        return self.server_manager.post_auto_csrf(s, url, *args, **kwargs)

    def _check_server_version(self, sv):
        return sv.startswith("Clarabot ")  # NOTE: server version is not known exactly

    def _check_query_me(self, me, target_me):
        return me["data"]["me"]["id"] == target_me

    def _check_auth_token_extract_id(self, token):
        pass  # NOTE: cannot check the token signature, only the server knows the HMAC secret

    def get_gql_url(self) -> str:
        return self.server_manager.get_gql_url()

    def get_wsgql_url(self) -> str:
        return self.server_manager.get_wsgql_url()

    async def session(self, *args) -> aiohttp.ClientSession:
        return await self.server_manager.session(*args)

    def session_storage(self, s: aiohttp.ClientSession) -> dict:
        return self.server_manager.session_storage(s)

    async def _pack_safe_radix32(self, value):
        return await self.server_manager.safe_radix32(value)

    async def ensure_logged_in_session(self, account_data: dict) -> aiohttp.ClientSession:
        s = await self.session(account_data["account_id"])
        if not await self.check_logged_in(s, account_data):
            await self.assert_login(
                s,
                account_data["email"],
                account_data["password"],
                private_kr_cls=PRIVATE_KEYRING_VERSION_MAP[account_data["_p_kr_version"]],
            )
        return s

    async def check_logged_in(self, s_ws: BaseGraphQlTransport, account_data: dict):
        return self._check_query_me(
            await self.gql_query_me(s_ws), await self._pack_safe_radix32(account_data["account_id"])
        )

    async def create_logged_in_wsc(self, account_data: dict) -> GraphQlWebSocketClient:
        s = await self.ensure_logged_in_session(account_data)
        wsc = await self.wsc(s)
        assert await self.check_logged_in(wsc, account_data)
        await self.assert_gql_query_ping(wsc)
        return wsc

    async def listing_response_admin_request(
        self,
        path: str,
        admin_session: str,
        room_id: Optional[int],
        nano_id: Optional[int],
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        restricted_roots: Optional[List[str]] = None,
        **request_kwargs,
    ):
        """
        The listing handler has an own utility to check overlap and multi-part responses.
        """
        request = {"type": "A-LISTING", "path": path}
        await self.negative_auth_admin_request(
            request, None, room_id, nano_id, req_wsc, req_kr, nano_kr, room_kr, **request_kwargs
        )
        # with mutation id and admin password
        request_payload = copy.deepcopy(request)
        request_payload["admin_session"] = admin_session
        responses = await self.nano_request_responses(
            msgpack_dumps(request_payload),
            room_id,
            nano_id,
            req_wsc,
            req_kr,
            nano_kr,
            room_kr,
            error=error,
            single_response=False,
            **request_kwargs,
        )
        if error is not None:
            return
        ref_ls = []
        for item in (
            Path(path).iterdir() if path else (get_root_paths() if restricted_roots is None else restricted_roots)
        ):
            if isinstance(item, Path):
                e = {"name": item.name}
            else:
                e = {"name": item}
                item = Path(item)
            try:
                rps = item.stat()
            except OSError:
                e["unresolved"] = True
                ref_ls.append(e)
            else:
                if stat.S_ISDIR(rps.st_mode):
                    e["mtime"] = rps.st_mtime
                    if (sys.platform.startswith("win") and not path) or (
                        not sys.platform.startswith("win") and item.is_mount()
                    ):
                        try:
                            e["du"] = shutil.disk_usage(item)
                        except OSError:
                            pass
                    ref_ls.append(e)

        def _check_du(du, d):
            if du:
                assert "total" in d and "free" in d, f"{du}\n{d}"
                t = d.pop("total")
                f = d.pop("free")
                assert (du.total / 2**30) == t, f"{du}\n{d}"
                if du.total == 0:
                    assert du.free == 0, f"{du}\n{d}"
                    assert f == t == 0, f"{du}\n{d}"
                else:
                    assert abs((du.free / du.total) - (f / t)) < 0.01, f"{du}\n{d}"
            else:
                assert "total" not in d and "free" not in d

        _check_du(shutil.disk_usage(path) if path else None, responses[0])
        ls = merge_listing_responses(responses)
        ref_ls.sort(key=lambda x: x["name"])
        ls.sort(key=lambda x: x["name"])
        assert len(ref_ls) == len(ls), f"{path}\n{ref_ls}\n{ls}"
        for ref, resp in zip(ref_ls, ls):
            _check_du(ref.pop("du", None), resp)
            assert ref == resp, f"{ref}\n{resp}"
        return ls

    async def new_admin_session_request(
        self,
        admin_password: str,
        mutation_name: str,
        room_id: Optional[int],
        nano_id: Optional[int],
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        **request_kwargs,
    ):
        r = {"type": "A-NEW-SESS", "admin_password": admin_password}
        r = await self.prepare_request_with_mutation_id(
            r, mutation_name, room_id, nano_id, req_wsc, req_kr, nano_kr, room_kr
        )
        r = await self.nano_request_response(
            msgpack_dumps(r), room_id, nano_id, req_wsc, req_kr, nano_kr, room_kr, error=error, **request_kwargs
        )
        assert set(r.keys()) == {"token", "ttl"}, str(r)
        assert r["ttl"] == 179, str(r)
        return r["token"]

    async def admin_log_stream(
        self,
        name: str,
        response_handler_cb: Callable[[int, dict], None],
        admin_session: str,
        room_id: Optional[int],
        nano_id: Optional[int],
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        new: bool = None,
        **request_kwargs,
    ):
        request = {"type": "A-LOG-S", "name": name, "admin_session": admin_session}
        if new is not None:
            request["new"] = new
        await self.nano_request_response(
            msgpack_dumps(request),
            room_id,
            nano_id,
            req_wsc,
            req_kr,
            nano_kr,
            room_kr,
            error=error,
            server_error=server_error,
            single_response=False,
            stream_processor_kwargs={"response_handler_cb": response_handler_cb},
            **request_kwargs,
        )
        if error is not None or server_error is not None:
            return

    async def admin_log_read(
        self,
        name: str,
        admin_session: str,
        room_id: Optional[int],
        nano_id: Optional[int],
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        **request_kwargs,
    ):
        request = {"type": "A-LOG-R", "name": name, "admin_session": admin_session}
        r = await self.nano_request_response(
            msgpack_dumps(request), room_id, nano_id, req_wsc, req_kr, nano_kr, room_kr, error=error, **request_kwargs
        )
        assert isinstance(r.get("bck"), dict), str(r)
        assert "logs" in r["bck"], str(r)
        return r["bck"]

    async def single_response_admin_request(
        self,
        request: dict,
        mutation_name: str,
        admin_session: str,
        room_id: Optional[int],
        nano_id: Optional[int],
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        error_before_deny: bool = False,
        **request_kwargs,
    ):
        """
        Checks the mutation and password protections.
        """
        # some errors have priority over mutation and admin password, so those can't be checked
        if not error_before_deny and error not in (NANO_REQUEST_ERROR_PAYLOAD_ROOM_INVALID,):
            await self.negative_auth_admin_request(
                request, mutation_name, room_id, nano_id, req_wsc, req_kr, nano_kr, room_kr, **request_kwargs
            )
        # with mutation id and admin password
        request_payload = copy.deepcopy(request)
        request_payload["admin_session"] = admin_session
        r = await self.prepare_request_with_mutation_id(
            request_payload, mutation_name, room_id, nano_id, req_wsc, req_kr, nano_kr, room_kr, **request_kwargs
        )
        return await self.nano_request_responses(
            msgpack_dumps(r), room_id, nano_id, req_wsc, req_kr, nano_kr, room_kr, error=error, **request_kwargs
        )

    async def negative_auth_admin_request(
        self,
        request: dict,
        mutation_name: Optional[str],
        room_id: Optional[int],
        nano_id: Optional[int],
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        **request_kwargs,
    ):
        """
        Checks the mutation and password protections.
        """
        if mutation_name is not None:
            # can't without mutation id
            await self.nano_request_responses(
                msgpack_dumps(request),
                room_id,
                nano_id,
                req_wsc,
                req_kr,
                nano_kr,
                room_kr,
                error=NANO_REQUEST_ERROR_MUTATION_ID,
                **request_kwargs,
            )
        # can't change name without admin password
        r = await self.prepare_request_with_mutation_id(
            copy.deepcopy(request), mutation_name, room_id, nano_id, req_wsc, req_kr, nano_kr, room_kr, **request_kwargs
        )
        await self.nano_request_responses(
            msgpack_dumps(r),
            room_id,
            nano_id,
            req_wsc,
            req_kr,
            nano_kr,
            room_kr,
            error=NANO_REQUEST_ERROR_ADMIN_SESSION,
            **request_kwargs,
        )

    async def prepare_request_with_mutation_id(
        self,
        request: dict,
        mutation_name: str,
        room_id: Optional[int],
        nano_id: Optional[int],
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        refresh: Optional[bool] = None,
        **request_kwargs,
    ) -> dict:
        if mutation_name not in self._mutation_ids or refresh:
            resps = await self.nano_request_responses(
                # NOTE: room id consistency is required
                msgpack_dumps({"type": "MUT-CRT", "room_id": safe_radix32.encode_fw(room_id) if room_id else None}),
                room_id,
                nano_id,
                req_wsc,
                req_kr,
                nano_kr,
                room_kr,
                error,
                **request_kwargs,
            )
            self._mutation_ids[mutation_name] = (resps[0]["token"], 1)
        mutation_key, mutation_seq = self._mutation_ids[mutation_name]
        self._mutation_ids[mutation_name] = (mutation_key, mutation_seq + 1)
        new_request = {
            NanoBaseRequest.MUTATION_KEY: mutation_key,
            NanoBaseRequest.MUTATION_SEQUENCE: mutation_seq,
        }
        new_request.update(request)
        return new_request

    async def old_listing_room(
        self,
        path: str,
        room_id: int,
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        ls_path: Optional[Path] = None,
        ls_allowed: Optional[set] = None,
        ls_unresolved: Optional[set] = None,
        adhoc_kr: Optional[AdhocKeyring] = None,
        **request_kwargs,
    ) -> Optional[Tuple[List[Any], List[Any], List[Any]]]:
        request = {"type": "FS-GET", "path": path, "room_id": safe_radix32.encode_fw(room_id)}
        responses = await self.nano_request_responses(
            msgpack_dumps(request),
            room_id,
            None,
            req_wsc,
            req_kr,
            nano_kr,
            room_kr,
            adhoc_kr=adhoc_kr,
            error=error,
            server_error=server_error,
            single_response=False,
            **request_kwargs,
        )
        if error is not None or server_error is not None:
            return
        ls = merge_listing_responses(responses)
        return self._check_resolve_ls(ls, ls_path, ls_allowed, ls_unresolved)

    async def listing_room_raw(
        self,
        path: str,
        room_id: int,
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        adhoc_kr: Optional[AdhocKeyring] = None,
        **request_kwargs,
    ) -> Optional[Tuple[bytes, int, List[dict]]]:
        request = {"type": "FS-LS", "path": path, "room_id": safe_radix32.encode_fw(room_id)}
        _, raw_responses = await self.nano_request_responses_raw(
            msgpack_dumps(request),
            room_id,
            None,
            req_wsc,
            req_kr,
            nano_kr,
            room_kr,
            adhoc_kr=adhoc_kr,
            error=error,
            server_error=server_error,
            single_response=False,
            **request_kwargs,
        )
        if error is not None or server_error is not None:
            return
        responses = [r.get("response", r) for r in raw_responses]
        assert len(responses) > 1, str(responses)
        assert "ls" in responses[1], str(responses[0])
        ls = list(itertools.chain(*[r["ls"] for r in responses[1:]]))
        return raw_responses[0]["cacheKey"], raw_responses[0]["count"], ls

    async def listing_room(
        self,
        *args,
        ls_path: Optional[Path] = None,
        ls_allowed: Optional[set] = None,
        ls_unresolved: Optional[set] = None,
        **kwargs,
    ) -> Optional[Tuple[List[Any], List[Any], List[Any]]]:
        tmp = await self.listing_room_raw(*args, **kwargs)
        if tmp:
            _, _, ls = tmp
            dirs, files, unresolved = self._check_resolve_ls(ls, ls_path, ls_allowed, ls_unresolved)
            return dirs, files, unresolved

    def _check_resolve_ls(
        self,
        ls: list,
        ls_path: Optional[Path] = None,
        ls_allowed: Optional[set] = None,
        ls_unresolved: Optional[set] = None,
    ):
        assert ls_path
        dirs = []
        files = []
        unresolved = []
        for item in ls_path.iterdir():
            if ls_allowed is not None and item.name not in ls_allowed:
                continue
            entry = {"name": item.name}
            try:  # symlink marker will be present unless the child is completely unresolvable
                if stat.S_ISLNK(item.lstat().st_mode):
                    entry["symlink"] = True
            except OSError:
                pass
            if ls_unresolved is not None and item.name in ls_unresolved:
                entry["unresolved"] = True
                unresolved.append(item.name)
            else:
                try:
                    s = item.stat()
                except OSError:
                    entry["unresolved"] = True
                    unresolved.append(item.name)
                else:
                    if stat.S_ISDIR(s.st_mode):
                        dirs.append(item.name)
                        entry["mtime"] = s.st_mtime
                    else:
                        files.append(item.name)
                        entry["mtime"] = s.st_mtime
                        entry["size"] = s.st_size
                        if files[-1].endswith(".png"):  # this works, but it is very naive
                            entry["imgb"] = 1
            try:
                ls.remove(entry)
            except ValueError:  # pragma: no cover
                assert False, f"{ls_allowed}\n{ls_unresolved}\n{item}\n{entry}\n{ls}\n{dirs}\n{files}"
        assert not ls, f"{ls}"
        return dirs, files, unresolved

    async def download_room(
        self,
        path: str,
        room_id: int,
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        file_path: Optional[Path] = None,
        part_count: Optional[int] = None,
        adhoc_kr: Optional[AdhocKeyring] = None,
        **request_kwargs,
    ) -> Optional[Any]:
        request = {"type": "FS-GET", "path": path, "room_id": safe_radix32.encode_fw(room_id)}
        responses = await self.nano_request_responses(
            msgpack_dumps(request),
            room_id,
            None,
            req_wsc,
            req_kr,
            nano_kr,
            room_kr,
            adhoc_kr=adhoc_kr,
            error=error,
            server_error=server_error,
            single_response=False,
            **request_kwargs,
        )
        if error is not None or server_error is not None:
            return
        assert file_path
        data = b""
        verified = 0
        await asyncio.sleep(0.001)
        if len(responses) == 1:
            assert responses[0].get("fs") == 0, str(responses)
        else:
            t = time.perf_counter() + 4.0
            assert len(responses) > 1, str(responses)
            for resp in responses[1:]:
                if time.perf_counter() > t:
                    await asyncio.sleep(0.001)
                    t = time.perf_counter() + 4.0
                data += resp["fc"]
                verified = self._verify_s_hmac(resp, data, verified, nano_kr)
        assert len(data) == verified
        assert len(data) == responses[0]["fs"]
        assert file_path.read_bytes() == data
        if part_count is not None:
            assert len(responses) - 1 == part_count, f"{len(responses)} {part_count}"
        return data

    def _verify_s_hmac(self, resp, data: bytes, verified: int, nano_kr: AbstractPeerAccountKeyring):
        if mac := resp.get("MAC"):
            assert (ds := resp.get("MACS")) is not None
            mac_data = msgpack_loads(mac)
            assert len(data) - mac_data["c"] == verified, f"{len(data)} {verified}\n{mac_data}"
            h = hashlib.sha512(mac_data["s"])
            h.update(data[verified:])
            assert mac_data["f"] == h.digest()[:32]
            ACCOUNT_SIGNATURE_CRYPTO_CONTEXT.load(AccountSignatureConfigLoadArgs(ds, nano_kr, mac))
            verified += mac_data["c"]
        return verified

    async def all_thumbnails_room(
        self,
        path: str,
        room_id: int,
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        part_count: Optional[int] = None,
        flat: bool = False,
        **request_kwargs,
    ) -> Optional[Any]:
        thumbs = {}
        bucket = 1
        while True:
            tmp = await self.thumbnails_room(
                path,
                bucket,
                room_id,
                req_wsc,
                req_kr,
                nano_kr,
                room_kr,
                error,
                server_error,
                part_count,
                **request_kwargs,
            )
            if not tmp:
                break
            thumbs[bucket] = tmp
            bucket += 1
        if flat:
            return list(itertools.chain.from_iterable(thumbs.values()))
        return thumbs

    async def thumbnails_room(
        self,
        path: str,
        bucket: int,
        room_id: int,
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        part_count: Optional[int] = None,
        adhoc_kr: Optional[AdhocKeyring] = None,
        **request_kwargs,
    ) -> Optional[Any]:
        request = {"type": "FS-THMB", "path": path, "b": bucket, "room_id": safe_radix32.encode_fw(room_id)}
        responses = await self.nano_request_responses(
            msgpack_dumps(request),
            room_id,
            None,
            req_wsc,
            req_kr,
            nano_kr,
            room_kr,
            adhoc_kr=adhoc_kr,
            error=error,
            server_error=server_error,
            single_response=False,
            **request_kwargs,
        )
        if error is not None or server_error is not None:
            return
        data = b""
        parts = []
        verified = 0
        await asyncio.sleep(0.001)
        if len(responses) == 1:
            assert responses[0].get("ts") is None, str(responses)
        else:
            t = time.perf_counter() + 4.0
            assert len(responses) > 1, str(responses)
            for resp in responses[1:]:
                if time.perf_counter() > t:
                    await asyncio.sleep(0.001)
                    t = time.perf_counter() + 4.0
                parts.append(resp["ts"])
                data += resp["ts"]
                verified = self._verify_s_hmac(resp, data, verified, nano_kr)
        assert len(data) == verified
        if part_count is not None:
            assert len(responses) - 1 == part_count, f"{len(responses)} {part_count}"
        ts = []
        for part in parts:
            ts.extend(msgpack_loads(part))
        return ts

    async def peek_room(
        self,
        path: str,
        room_id: int,
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        options: Optional[int] = None,
        part_count: Optional[int] = None,
        adhoc_kr: Optional[AdhocKeyring] = None,
        **request_kwargs,
    ):
        request = {"type": "FS-PEEK", "path": path, "room_id": safe_radix32.encode_fw(room_id)}
        if options is not None:
            request["o"] = options
        responses = await self.nano_request_responses(
            msgpack_dumps(request),
            room_id,
            None,
            req_wsc,
            req_kr,
            nano_kr,
            room_kr,
            adhoc_kr=adhoc_kr,
            error=error,
            server_error=server_error,
            single_response=options is None or not (options & 2),
            **request_kwargs,
        )
        if error is not None or server_error is not None:
            return
        data = b""  # for preview if any
        verified = 0
        await asyncio.sleep(0.001)
        assert isinstance(responses[0].get("pk"), dict), str(responses)
        assert "pc" not in responses[0], str(responses)
        if len(responses) > 1:
            for resp in responses[1:]:
                assert "pk" not in resp, str(responses)
                data += resp["pc"]
                verified = self._verify_s_hmac(resp, data, verified, nano_kr)
        assert len(data) == verified
        if part_count is not None:
            assert len(responses) - 1 == part_count, f"{len(responses)} {part_count}"
            if part_count == 0:
                return responses[0]["pk"]
        # return peek data from first part and preview image data if any
        return responses[0]["pk"], data or None

    async def search_room(
        self,
        query: str,
        room_id: int,
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        lang: Optional[str] = None,
        **request_kwargs,
    ):
        request = {"type": "SEARCH", "query": query, "lang": lang, "room_id": safe_radix32.encode_fw(room_id)}
        response = await self.nano_request_response(
            msgpack_dumps(request),
            room_id,
            None,
            req_wsc,
            req_kr,
            nano_kr,
            room_kr,
            error=error,
            server_error=server_error,
            **request_kwargs,
        )
        if error is not None or server_error is not None:
            return
        # return relevant fields only
        return {"docs": response["docs"]}

    async def mkdir_room(
        self,
        path: str,
        room_id: int,
        mutation_name: str,
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        **request_kwargs,
    ):
        request = {"type": "FS-MKD", "path": path, "room_id": safe_radix32.encode_fw(room_id)}
        return await self.mutate_room(
            request, room_id, mutation_name, req_wsc, req_kr, nano_kr, room_kr, error, server_error, **request_kwargs
        )

    async def del_room(
        self,
        path: str,
        room_id: int,
        mutation_name: str,
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        **request_kwargs,
    ):
        request = {"type": "FS-DEL", "path": path, "room_id": safe_radix32.encode_fw(room_id)}
        return await self.mutate_room(
            request, room_id, mutation_name, req_wsc, req_kr, nano_kr, room_kr, error, server_error, **request_kwargs
        )

    async def move_room(
        self,
        src_path: str,
        new_path: str,
        room_id: int,
        mutation_name: str,
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        **request_kwargs,
    ):
        request = {
            "type": "FS-MOV",
            "src_path": src_path,
            "new_path": new_path,
            "room_id": safe_radix32.encode_fw(room_id),
        }
        return await self.mutate_room(
            request, room_id, mutation_name, req_wsc, req_kr, nano_kr, room_kr, error, server_error, **request_kwargs
        )

    async def lock_room(
        self,
        path: str,
        token: str,
        op: str,
        room_id: int,
        mutation_name: str,
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        old_token: Optional[str] = None,
        **request_kwargs,
    ):
        request = {
            "type": "FS-L-X",
            "path": path,
            "token": token,
            "op": op,
            "room_id": safe_radix32.encode_fw(room_id),
        }
        if old_token is not None:
            request["old"] = old_token
        r = await self.mutate_room(
            request, room_id, mutation_name, req_wsc, req_kr, nano_kr, room_kr, error, server_error, **request_kwargs
        )
        if not error:
            ks = set(r.keys())
            assert ks.issubset({"c"}), str(r)
            return r.get("c")

    async def get_lock_room(
        self,
        path: str,
        room_id: int,
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        **request_kwargs,
    ):
        request = {"type": "FS-L-G", "path": path, "room_id": safe_radix32.encode_fw(room_id)}
        response = await self.nano_request_response(
            msgpack_dumps(request),
            room_id,
            None,
            req_wsc,
            req_kr,
            nano_kr,
            room_kr,
            error=error,
            server_error=server_error,
            **request_kwargs,
        )
        if error is not None or server_error is not None:
            return
        return response["t"]

    async def document_ws_prepare(
        self,
        path: str,
        room_id: int,
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        check_wsd_ver: Optional[str] = None,
        check_open_requires_template: bool = False,
        **request_kwargs,
    ):
        request = {"type": "DOC-PRE", "path": path, "room_id": safe_radix32.encode_fw(room_id)}
        response = await self.nano_request_response(
            msgpack_dumps(request),
            room_id,
            None,
            req_wsc,
            req_kr,
            nano_kr,
            room_kr,
            error=error,
            server_error=server_error,
            **request_kwargs,
        )
        if error is not None or server_error is not None:
            return
        t_wsd_ver = response.get("wvh")
        assert isinstance(t_wsd_ver, str), str(response)
        if check_wsd_ver is not None:
            assert t_wsd_ver == check_wsd_ver, f"{t_wsd_ver} {check_wsd_ver}"
        t_file_id = response.get("wfi")
        assert isinstance(t_file_id, str), str(response)
        t_acc = response.get("wat")
        assert isinstance(t_file_id, str), str(response)
        if check_open_requires_template:
            assert response.get("ort") is True, str(response)
        else:
            assert "ort" not in response, str(response)
        return t_wsd_ver, t_file_id, t_acc

    async def document_ws_open(
        self,
        wsd_ws_url: str,
        room_id: int,
        mutation_name: str,
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        **request_kwargs,
    ):
        request = {"type": "DOC-OPN", "wwu": wsd_ws_url, "room_id": safe_radix32.encode_fw(room_id)}
        response = await self.mutate_room(
            request, room_id, mutation_name, req_wsc, req_kr, nano_kr, room_kr, error, server_error, **request_kwargs
        )
        session = response.get("session")
        assert isinstance(session, str), str(response)
        return session

    async def document_ws_close(
        self,
        session: str,
        room_id: int,
        mutation_name: str,
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        **request_kwargs,
    ):
        request = {"type": "DOC-CLS", "s": session, "room_id": safe_radix32.encode_fw(room_id)}
        response = await self.mutate_room(
            request, room_id, mutation_name, req_wsc, req_kr, nano_kr, room_kr, error, server_error, **request_kwargs
        )

    async def document_ws_read(
        self,
        session: str,
        response_handler_cb: Callable[[int, dict], None],
        room_id: int,
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        **request_kwargs,
    ):
        request = {"type": "DOC-RX", "s": session, "room_id": safe_radix32.encode_fw(room_id)}
        await self.nano_request_response(
            msgpack_dumps(request),
            room_id,
            None,
            req_wsc,
            req_kr,
            nano_kr,
            room_kr,
            error=error,
            server_error=server_error,
            single_response=False,
            stream_processor_kwargs={"response_handler_cb": response_handler_cb},
            **request_kwargs,
        )
        if error is not None or server_error is not None:
            return

    async def document_ws_write(
        self,
        session: str,
        msgs: List[Tuple[int, int, int, bytes]],
        room_id: int,
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        **request_kwargs,
    ):
        request = {"type": "DOC-TX", "s": session, "msgs": msgs, "room_id": safe_radix32.encode_fw(room_id)}
        await self.nano_request_response(
            msgpack_dumps(request),
            room_id,
            None,
            req_wsc,
            req_kr,
            nano_kr,
            room_kr,
            error=error,
            server_error=server_error,
            **request_kwargs,
        )
        if error is not None or server_error is not None:
            return

    async def put_room(
        self,
        req: dict,
        room_id: int,
        mutation_name: str,
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        **request_kwargs,
    ):
        request = {"type": "FS-PUT", "room_id": safe_radix32.encode_fw(room_id)}
        request.update(req)
        return await self.mutate_room(
            request, room_id, mutation_name, req_wsc, req_kr, nano_kr, room_kr, error, server_error, **request_kwargs
        )

    async def mutate_room(
        self,
        request: dict,
        room_id: int,
        mutation_name: str,
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        **request_kwargs,
    ):
        request = await self.prepare_request_with_mutation_id(
            request, mutation_name, room_id, None, req_wsc, req_kr, nano_kr, room_kr, **request_kwargs
        )
        response = await self.nano_request_response(
            msgpack_dumps(request),
            room_id,
            None,
            req_wsc,
            req_kr,
            nano_kr,
            room_kr,
            error=error,
            server_error=server_error,
            **request_kwargs,
        )
        if error is not None or server_error is not None:
            return
        return response

    async def create_adhoc_fs(
        self,
        path: str,
        room_id: int,
        mutation_name: str,
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        ttl: Optional[int] = None,
        **request_kwargs,
    ) -> Optional[Tuple[str, AdhocKeyring]]:
        request = {"type": "ROOM-ADHOC", "fs_path": path, "ttl": ttl, "room_id": safe_radix32.encode_fw(room_id)}
        response = await self.mutate_room(
            request, room_id, mutation_name, req_wsc, req_kr, nano_kr, room_kr, error, server_error, **request_kwargs
        )
        if error is not None or server_error is not None:
            return
        token = response.get("token")
        assert isinstance(token, str), str(response)
        key1 = response.get("key1")
        assert isinstance(key1, bytes), str(response)
        kr = AdhocKeyring.load(safe_radix32.encode_fw(room_id), {AdhocKeyring.KEY1: key1})
        return token, kr

    async def reset_adhoc_tokens(
        self,
        room_id: int,
        mutation_name: str,
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        **request_kwargs,
    ):
        request = {"type": "ROOM-AHRST", "room_id": safe_radix32.encode_fw(room_id)}
        response = await self.mutate_room(
            request, room_id, mutation_name, req_wsc, req_kr, nano_kr, room_kr, error, server_error, **request_kwargs
        )
        if error is not None or server_error is not None:
            return
        return response

    async def count_adhoc_tokens(
        self,
        room_id: int,
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        **request_kwargs,
    ):
        request = {"type": "ROOM-AHCNT", "room_id": safe_radix32.encode_fw(room_id)}
        response = await self.nano_request_response(
            msgpack_dumps(request),
            room_id,
            None,
            req_wsc,
            req_kr,
            nano_kr,
            room_kr,
            error=error,
            server_error=server_error,
            **request_kwargs,
        )
        if error is not None or server_error is not None:
            return
        count = response.get("count")
        assert isinstance(count, int), str(response)
        return count

    async def create_inter_copy(
        self,
        job_attrs: dict,
        room_id: int,
        mutation_name: str,
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        **request_kwargs,
    ):
        request = {
            "type": "RINJ-ADD",
            "job_type": "copy",
            "job_attrs": job_attrs,
            "room_id": safe_radix32.encode_fw(room_id),
        }
        response = await self.mutate_room(
            request, room_id, mutation_name, req_wsc, req_kr, nano_kr, room_kr, error, server_error, **request_kwargs
        )
        if error is not None or server_error is not None:
            return
        job_id = response.get("job_id")
        assert isinstance(job_id, int), str(response)
        return job_id

    async def delete_inter_job(
        self,
        job_id: int,
        room_id: int,
        mutation_name: str,
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        **request_kwargs,
    ):
        request = {"type": "RINJ-DEL", "job_id": job_id, "room_id": safe_radix32.encode_fw(room_id)}
        response = await self.mutate_room(
            request, room_id, mutation_name, req_wsc, req_kr, nano_kr, room_kr, error, server_error, **request_kwargs
        )
        if error is not None or server_error is not None:
            return
        return response

    async def describe_inter_job(
        self,
        job_id: int,
        room_id: int,
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        **request_kwargs,
    ):
        request = {"type": "RINJ-DESC", "job_id": job_id, "room_id": safe_radix32.encode_fw(room_id)}
        response = await self.nano_request_response(
            msgpack_dumps(request),
            room_id,
            None,
            req_wsc,
            req_kr,
            nano_kr,
            room_kr,
            error,
            server_error,
            **request_kwargs,
        )
        if error is not None or server_error is not None:
            return
        job = response.get("job")
        assert isinstance(job, dict), str(response)
        return job

    async def list_inter_jobs(
        self,
        room_id: int,
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        **request_kwargs,
    ):
        request = {"type": "RINJ-LIST", "room_id": safe_radix32.encode_fw(room_id)}
        response = await self.nano_request_response(
            msgpack_dumps(request),
            room_id,
            None,
            req_wsc,
            req_kr,
            nano_kr,
            room_kr,
            error,
            server_error,
            **request_kwargs,
        )
        if error is not None or server_error is not None:
            return
        jobs = response.get("jobs")
        assert isinstance(jobs, list), str(response)
        return jobs

    def calc_smac(self, data_list):
        smac = hashlib.sha256()
        for data in data_list:
            smac.update(hashlib.sha256(data).digest()[:16])
        return smac.digest()[:16]

    async def nano_request_response(self, *args, **kwargs) -> Any:
        responses = await self.nano_request_responses(*args, **kwargs)
        assert len(responses) == 1, str(responses)
        return responses[0]

    async def nano_request_responses(self, *args, **kwargs) -> List[Any]:
        req, responses = await self.nano_request_responses_raw(*args, **kwargs)
        return [r.get("response", r) for r in responses]

    async def nano_request_responses_raw(
        self,
        request_payload: bytes,
        room_id: Optional[int],
        nano_id: Optional[int],
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,  # error code from Nano
        server_error: Optional[str] = None,  # error from client server
        single_response: bool = True,
        request_payload_encrypted: bool = False,
        continues_from_part: bool = True,
        stream_processor_cls: Type[TestingNanoResponseStreamProcessor] = TestingNanoResponseStreamProcessor,
        stream_processor_kwargs: dict = None,
        adhoc_kr: Optional[AdhocKeyring] = None,
        **request_kwargs,
    ) -> Tuple[bytes, List[dict]]:
        assert server_error is None or error is None
        # encrypt request
        if request_payload_encrypted:
            req = request_payload
        elif req_kr.id == nano_kr.id:
            assert room_kr is None
            req = NANO_OWN_CRYPTO_CONTEXT.dump(NanoOwnConfigDumpArgs(request_payload, req_kr, time.time()))
        elif adhoc_kr:
            req = NANO_ADHOC_CRYPTO_CONTEXT.dump(
                NanoAdhocConfigDumpArgs(request_payload, req_kr, nano_kr, adhoc_kr, time.time())
            )
        else:
            req = NANO_CRYPTO_CONTEXT.dump(NanoConfigDumpArgs(request_payload, req_kr, nano_kr, room_kr, time.time()))
        part_delta = request_kwargs.get("part", 1) - 1 if continues_from_part else 0
        sp_kw = stream_processor_kwargs or {}
        stream_processor = stream_processor_cls(
            req,
            room_id,
            nano_id,
            req_kr,
            nano_kr,
            room_kr,
            adhoc_kr,
            error,
            server_error,
            single_response,
            part_delta,
            **sp_kw,
        )
        assert "stream_processor_cb" not in request_kwargs, str(request_kwargs)
        request_kwargs["stream_processor_cb"] = stream_processor.feed
        # send request and process response(s)
        if nano_id is not None:
            assert room_id is None
            responses = await self.nano_admin_request(req_wsc, nano_id, req, **request_kwargs)
        elif room_id is not None:
            responses = await self.nano_request(req_wsc, room_id, req, **request_kwargs)
        else:
            assert False, f"{room_id} {nano_id}"
        stream_processor.finish()
        return req, responses

    async def get_privy_info(
        self,
        room_id: int,
        req_wsc: GraphQlWebSocketClient,
        req_kr: AbstractSelfAccountKeyring,
        nano_kr: AbstractPeerAccountKeyring,
        room_kr: Optional[AbstractRoomKeyring] = None,
        error: Optional[int] = None,
        server_error: Optional[str] = None,
        **request_kwargs,
    ):
        request = {"type": "INFO", "room_id": safe_radix32.encode_fw(room_id)}
        response = await self.nano_request_response(
            msgpack_dumps(request),
            room_id,
            None,
            req_wsc,
            req_kr,
            nano_kr,
            room_kr,
            error=error,
            server_error=server_error,
            **request_kwargs,
        )
        if error is not None or server_error is not None:
            return
        assert isinstance(response, dict), str(response)
        assert isinstance(response.get("name"), str), str(response)
        assert isinstance(response.get("version"), str), str(response)
        return response

    # PRIVY
    async def gql_create_new_id(self, s_ws: BaseGraphQlTransport, type_: Optional[str] = None):
        return (await self.assert_gql_okay(s_ws, ref_mutation_create_new_id(type_)))["data"]["createNewId"]

    async def gql_privy_rooms(self, s_ws: BaseGraphQlTransport):
        result = await self.assert_gql_okay(s_ws, ref_query_privy_rooms())
        privy_rooms = result["data"]["privyRooms"]
        assert isinstance(privy_rooms, list), str(result)
        return privy_rooms

    async def gql_privy_setup(
        self,
        s_ws: BaseGraphQlTransport,
        room_id: str,
        privy_setup_key: bytes,
        self_room_secret: bytes,
        privy_room_secret: bytes,
        errors=None,
    ):
        mutation = ref_mutate_privy_setup(room_id, privy_setup_key, self_room_secret, privy_room_secret)
        if errors is not None:
            await self.assert_gql_errs(s_ws, mutation, errors)
            return
        result = await self.assert_gql_okay(s_ws, mutation)
        assert result == SETUP_PRIVY_RESULT
