"""
R1FS - Ratio1 base IPFS utility functions.


NOTE: 
  - Following the bootstrapping of this module, it takes a few minutes for the relay
    to be connected and the IPFS daemon to be fully operational so sometimes, after
    the start of the engine, the first few `get` operations may fail.


Installation:

1. On the dev node or seed node run ifps_keygen and generate `swarm_key_base64.txt` then
   save this key to the environment variable `EE_SWARM_KEY_CONTENT_BASE64` on the seed 
   oracles as well as in a file.
     
2. On seed node create `ipfs_setup`, copy the files from the `ipfs_setup` including the
  key file.
  
3. Run `setup.sh` on the seed node or:

    ```bash
    #!/bin/bash
    wget https://dist.ipfs.tech/kubo/v0.32.1/kubo_v0.32.1_linux-amd64.tar.gz && \
      tar -xvzf kubo_v0.32.1_linux-amd64.tar.gz && \
      cd kubo && \
      bash install.sh
    ipfs init
    ipfs config --json Swarm.EnableRelayHop true

    ./write_key.sh
    ```
  The `write_key.sh` script should contain the following:
  
    ```bash 
    cat swarm_key_base64.txt | base64 -d > /root/.ipfs/swarm.key
    cat /root/.ipfs/swarm.key
    ```
  
4. Continue on the seed node and run either manually (NOT recommended) or via a systemd
   the ifps daemon using `./launch_service.sh` that basically does:
   
    ```bash
    cp ipfs.service /etc/systemd/system/ipfs.service
    sudo systemctl daemon-reload
    sudo systemctl enable ipfs
    sudo systemctl start ipfs
    ./show.sh
    ```
    
Documentation url: https://docs.ipfs.tech/reference/kubo/cli/#ipfs

"""
import subprocess
import json
from datetime import datetime, timezone
import base64
import time
import os
import tempfile
import uuid
import requests
import hashlib
import shutil
import gzip
from io import BytesIO
import ssl
from requests.auth import HTTPBasicAuth
import tempfile
import random
import ipaddress
import signal

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

DEFAULT_SECRET = "ratio1"
DEFAULT_FILENAME_JSON = "data.json"

from threading import RLock

__VER__ = "0.2.2"

# empirically determined minimum connection age for IPFS relay
DEFAULT_MIN_CONNECTION_AGE = 0 # seconds


class IPFSCt:
  EE_IPFS_RELAY_ENV_KEY = "EE_IPFS_RELAY"
  EE_SWARM_KEY_CONTENT_BASE64_ENV_KEY = "EE_SWARM_KEY_CONTENT_BASE64"
  EE_IPFS_RELAY_API_KEY = "EE_IPFS_RELAY_API"
  EE_IPFS_API_KEY_BASE64_KEY = "EE_IPFS_API_KEY_BASE64"
  EE_IPFS_CERTIFICATE_BASE64_KEY = "EE_IPFS_CERTIFICATE_BASE64"
  # This workaround was introduced to prefer the host-local relay path for
  # containers running on the same machine as the relay. In testing, the local
  # path proved good enough for peer connectivity but not consistently safe for
  # block transfer, so it remains opt-in until a stronger data-plane proof is
  # implemented.
  EE_R1FS_SAMEHOST_RELAY_FIX = "EE_R1FS_SAMEHOST_RELAY_FIX"
  R1FS_DOWNLOADS = "ipfs_downloads"
  R1FS_UPLOADS = "ipfs_uploads"
  CACHE_ROOT = "_local_cache"
  TEMP_DOWNLOAD = os.path.join(f"./{CACHE_ROOT}/_output", R1FS_DOWNLOADS)
  TEMP_UPLOAD = os.path.join(f"./{CACHE_ROOT}/_output", R1FS_UPLOADS)
  SAMEHOST_FIX_MARKER = ".r1fs_samehost_relay_fix.json"
  SAMEHOST_FIX_NEGATIVE_TTL_SECONDS = 3600
  
  TIMEOUT = 90 # seconds
  # The reprovide operation is very heavy and should be done infrequently.
  # The kubo documentation advises on 22h. This was previously on 1m and will be
  # increased to 10h to reduce the load on the relay(s).
  REPROVIDER = "10h"
  REPROVIDER_STRATEGY = "pinned+mfs"


ERROR_TAG = "Unknown"

COLOR_CODES = {
  "g": "\033[92m",
  "r": "\033[91m",
  "b": "\033[94m",
  "y": "\033[93m",
  "m": "\033[95m",
  'd': "\033[90m", # dark gray
  "reset": "\033[0m"
}

def log_info(msg: str, color="reset", **kwargs):
  timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  color_code = COLOR_CODES.get(color, COLOR_CODES["reset"])
  reset_code = COLOR_CODES["reset"]
  print(f"{color_code}[{timestamp}] {msg}{reset_code}", flush=True)
  return

class SimpleLogger:
  def P(self, *args, **kwargs):
    log_info(*args, **kwargs)
    return
  
def require_ipfs_started(method):
  """
  decorator to ensure the IPFS is started before executing the method.
  
  parameters
  ----------
  method : callable
      the method to be decorated.
  
  returns
  -------
  callable
      the wrapped method that checks the 'started' attribute.
  
  raises
  ------
  RuntimeError
      if the instance's 'started' attribute is False.
  """
  def wrapper(self, *args, **kwargs):
    if not self.ipfs_started:
      msg = f"R1FS ERROR: {method.__name__} FAILED. R1FS.ipfs_started=={self.ipfs_started}"
      raise RuntimeError(msg)
    return method(self, *args, **kwargs)
  return wrapper  



class R1FSEngine:
  
  # __new__ holds this lock while constructing and starting the singleton instance.
  # Startup may re-enter code paths that need the same class-level guard, so this
  # must stay re-entrant to avoid self-deadlocking the initializing thread.
  _lock: RLock = RLock()
  __instances = {}

  def __new__(
    cls, 
    name: str = "default", 
    logger: any = None, 
    downloads_dir: str = None,
    uploads_dir: str = None,
    base64_swarm_key: str = None,
    ipfs_relay_api: str = None,
    ipfs_api_key_username: str = None,
    ipfs_api_key_password: str = None,
    ipfs_certificate_path: str = None,
    ipfs_relay: str = None,   
    debug=False,     
    min_connection_age: int = DEFAULT_MIN_CONNECTION_AGE,
  ):
    with cls._lock:
      if name not in cls.__instances:
        instance = super(R1FSEngine, cls).__new__(cls)
        instance._build(
          name=name, logger=logger,
          downloads_dir=downloads_dir,
          uploads_dir=uploads_dir,
          base64_swarm_key=base64_swarm_key,
          ipfs_relay=ipfs_relay,
          ipfs_relay_api=ipfs_relay_api,
          ipfs_api_key_username=ipfs_api_key_username,
          ipfs_api_key_password=ipfs_api_key_password,
          ipfs_certificate_path=ipfs_certificate_path,
          debug=debug,
          min_connection_age=min_connection_age,
        )
        cls.__instances[name] = instance
      else:
        instance = cls.__instances[name]
    return instance
  
  
  # base
  if True:
      
    def _build(
      self, 
      name: str = "default",
      logger: any = None, 
      downloads_dir: str = None,
      uploads_dir: str = None,
      base64_swarm_key: str = None, 
      ipfs_relay: str = None,
      ipfs_relay_api: str = None,
      ipfs_api_key_username: str = None,
      ipfs_api_key_password: str = None,
      ipfs_certificate_path: str = None,
      min_connection_age: int = DEFAULT_MIN_CONNECTION_AGE,
      debug=False,     
    ):
      """
      Initialize the IPFS wrapper with a given logger function.
      By default, it uses the built-in print function for logging.
      
      
      """
      self.__DEFAULT_SECRET = DEFAULT_SECRET
      self.__name = name
      if logger is None:
        logger = SimpleLogger()

      self.logger = logger

      self.__ipfs_started = False
      self.__ipfs_address = None
      self.__ipfs_id = None
      self.__ipfs_id_result = None
      self.__min_connection_age = min_connection_age
      self.__connected_at = None
      self.__ipfs_agent = None
      self.__uploaded_files = {}
      self.__downloaded_files = {}
      self.__base64_swarm_key = base64_swarm_key
      self.__ipfs_relay = ipfs_relay
      self.__ipfs_relay_api = ipfs_relay_api
      self.__ipfs_api_key_username = ipfs_api_key_username
      self.__ipfs_api_key_password = ipfs_api_key_password
      self.__ipfs_certificate_path = ipfs_certificate_path
      self.__ipfs_home = None
      self.__downloads_dir = downloads_dir
      self.__uploads_dir = uploads_dir    
      self.__debug = debug
      self.__relay_check_cnt = 0
      self.__peers = []
      self.__samehost_fix_attempted = False
      
      self.startup()
      return
    
    def startup(self):
      
      if self.__downloads_dir is None:
        if hasattr(self.logger, "get_output_folder"):
          output_folder = self.logger.get_output_folder()
          self.Pd("Using output folder as base: {}".format(output_folder))
          self.__downloads_dir = os.path.join(
            output_folder,
            IPFSCt.R1FS_DOWNLOADS
          )
        else:
          self.__downloads_dir = IPFSCt.TEMP_DOWNLOAD
      #end if downloads_dir    
      os.makedirs(self.__downloads_dir, exist_ok=True)    
      
      if self.__uploads_dir is None:
        if hasattr(self.logger, "get_output_folder"):
          self.__uploads_dir = os.path.join(
            self.logger.get_output_folder(),
            IPFSCt.R1FS_UPLOADS
          )
        else:
          self.__uploads_dir = IPFSCt.TEMP_UPLOAD
      os.makedirs(self.__uploads_dir, exist_ok=True)    

      self.maybe_reset_ipfs()

      self.maybe_start_ipfs(
        base64_swarm_key=self.__base64_swarm_key,
        ipfs_relay=self.__ipfs_relay,
        ipfs_relay_api=self.__ipfs_relay_api,
        ipfs_api_key_username=self.__ipfs_api_key_username,
        ipfs_api_key_password=self.__ipfs_api_key_password,
        ipfs_certificate_path=self.__ipfs_certificate_path
      )
      return
      
      
    def P(self, s, *args, **kwargs):
      s = "[R1FS] " + s
      color = kwargs.pop("color", "d")
      kwargs["color"] = color
      self.logger.P(s, *args, **kwargs)
      return
    
    def Pd(self, s, *args, **kwargs):
      if self.__debug:
        s = "[R1FS][DEBUG] " + s
        color = kwargs.pop("color", "d")
        color = "d" if color != 'r' else "r"
        kwargs["color"] = color
        self.logger.P(s, *args, **kwargs)
      return

    def _hash_secret(self, secret: str) -> bytes:
      secret = str(secret) # to be sure that the passed secret is of string type.
      # Convert text to bytes, then hash with SHA-256 => 32-byte key
      return hashlib.sha256(secret.encode("utf-8")).digest()

  # Private, yet visible (public) helpers.
  if True:
    def _set_debug(self):
      """
      Force debug mode on.
      """
      self.__debug = True
      return

    def _set_min_connection_age(self, min_connection_age: int):
      """
      @Deprecated: Don't use this method anymore, Warm up is not used anymore.
      Set the minimum connection age for IPFS to be considered warmed up.
      """
      return

  # Public properties
  if True:
    @property
    def ipfs_id(self):
      return self.__ipfs_id
    
    @property
    def ipfs_address(self):
      return self.__ipfs_address
    
    @property
    def ipfs_relay(self):
      return self.__ipfs_relay
    
    @property
    def ipfs_agent(self):
      return self.__ipfs_agent
    
    @property
    def ipfs_started(self):
      return self.__ipfs_started

    @property
    def ipfs_connected(self):
      return self.ipfs_started and self.__connected_at is not None

    @property
    def download_folder(self):
      return self.__downloads_dir
    
    
    @property
    def peers(self):
      return self.__peers
    
    @property
    def swarm_peers(self):
      return self.peers
    
    @property
    def uploaded_files(self):
      return self.__uploaded_files
    
    @property
    def downloaded_files(self):
      return self.__downloaded_files
        
    @property
    def connected_at(self):
      return self.__connected_at
    
    @property
    def ipfs_home(self):
      """ return the IPFS home directory from the environment variable IPFS_PATH """
      return os.environ.get("IPFS_PATH")
  
  # boilerplate methods
  if True:
    def _get_unique_name(self, prefix="r1fs", suffix=""):
      str_id = str(uuid.uuid4()).replace("-", "")[:8]
      return f"{prefix}_{str_id}{suffix}"
    
    def _get_unique_upload_name(self, prefix="r1fs", suffix=""):
      return os.path.join(self.__uploads_dir, self._get_unique_name(prefix, suffix))
    
    def _get_unique_or_complete_upload_name(self, fn=None, prefix="r1fs", suffix=""):
      if fn is not None and os.path.dirname(fn) == "":
        # Don't add suffix if filename already ends with it
        if suffix and fn.endswith(suffix):
          return os.path.join(self.__uploads_dir, fn)
        return os.path.join(self.__uploads_dir, f"{fn}{suffix}")
      return self._get_unique_upload_name(prefix, suffix=suffix)
    

    def _get_swarm_peers(self):
      peer_lines = []
      try:
        out = subprocess.run(
          ["ipfs", "swarm", "peers"],
          capture_output=True, text=True, timeout=5
        )
        if out.returncode == 0:
          peer_lines = out.stdout.strip().split("\n")
          peer_lines = [line.strip() for line in peer_lines if line.strip()]
          self.Pd(f"Swarm peers: {peer_lines}")
          self.__peers = peer_lines
        else:
          self.__peers = []
        # found = len(self.__peers) > 0
      except Exception as e:
        self.P(f"Error getting swarm peers: {e}", color='r')
      return peer_lines


    def _check_and_record_relay_connection(
      self, 
      max_check_age : int = 3600, 
      debug=False
    ) -> bool:
      """
      Checks if we're connected to the relay_peer_id by parsing 'ipfs swarm peers'.
      If connected and we haven't recorded connected_at yet, we set it.
      """
      log_func = self.P if debug else self.Pd
      relay_found = False
      relay_info = self.__parse_multiaddr(self.__ipfs_relay)
      relay_peer_id = relay_info["peer_id"] if relay_info is not None else None
      try:
        if self.connected_at is not None:
          # Already connected, lets see if last check was recent enough:
          elapsed_time = time.time() - self.connected_at
          if elapsed_time < max_check_age:
            relay_found = True
            log_func(f"Relay check #{self.__relay_check_cnt}: relay peer already recorded for {elapsed_time:.1f}s; skipping recheck.")
            # If we are connected and the last check was recent enough, return True:
          else:
            log_func(f"Relay check #{self.__relay_check_cnt}: last relay check is {elapsed_time:.1f}s old; rechecking swarm peers.")
            # If we are connected but the last check was too long ago, check again:
          #end if needs recheck or not
        #end if connected_at is not None
        if not relay_found:         
          self.__relay_check_cnt += 1
          log_func(f"Relay check #{self.__relay_check_cnt}: scanning swarm peers for relay peer id.")
          peer_lines = self._get_swarm_peers()
          if len(peer_lines) > 0:
            log_func(f"Relay check #{self.__relay_check_cnt}: found {len(peer_lines)} swarm peer(s).")
            self.__peers = peer_lines
            for line in peer_lines:
              # After the workaround is applied, the relay may appear through the
              # local-gateway multiaddr instead of its public address. Matching by
              # peer id keeps the connection check valid in both states.
              if relay_peer_id and relay_peer_id in line:
                # Record the time if not already set
                relay_found = True
                log_func(f"Relay check #{self.__relay_check_cnt}: relay peer present on {line.strip()}")
                break
              #end if
            #end for
            # now reset the connected_at time if we found the relay peer
            if relay_found:
              # TODO: maybe add first & last connected time
              if self.__connected_at is None:
                # If we found the relay peer and connected_at was not set, set it now:
                self.__connected_at = time.time()
              str_connected = self.logger.time_to_str(self.connected_at)
              self.P(f"Relay check #{self.__relay_check_cnt}: relay connection recorded at {str_connected}.")
            else:              
              # TODO: maybe add first & last connected time
              # self.__connected_at = None # this is already None or the first connection
              log_func("Relay check #{}: relay peer {} not present in swarm peers:\n{}".format(
                self.__relay_check_cnt, relay_peer_id or self.__ipfs_relay.split('/')[2],
                json.dumps(peer_lines, indent=2)
                ), color='r'\
              )
            #end if relay_found or not
          else:
            log_func(f"Relay check #{self.__relay_check_cnt}: swarm peer list is empty.", color='r')
          #end if len(peer_lines) > 0
      except subprocess.TimeoutExpired:
        self.P(f"Relay check #{self.__relay_check_cnt}: timed out while reading swarm peers.", color='r')
        relay_found = False
      except Exception as e:
        self.P(f"Relay check #{self.__relay_check_cnt}: failed to inspect swarm peers: {e}", color='r')
        relay_found = False
      #end try
      return relay_found


    def __set_reprovider_interval(self):
      # Command to set the Reprovider.Interval to 1 minute
      cmd = ["ipfs", "config", "--json", "Reprovider.Interval", f'"{IPFSCt.REPROVIDER}"']
      result = self.__run_command(cmd)
      return result


    # TODO: Create a function for setting variables below.
    def __disable_auto_tls(self):
      result = self.__run_command(
        ["ipfs", "config", "--json", "AutoTLS.Enabled", "false"]
      )
      return result

    def __set_routing_type(self):
      # Routing.Type: "dht" vs "dhtclient"
      # - "dht" lets the node use the DHT and automatically act as a DHT *server* when it seems reachable
      #   (serves/handles other peers' DHT queries → more CPU/bandwidth/connections).
      # - "dhtclient" is lookup-only: it can query the DHT but never serves DHT traffic → lighter and more predictable.
      #   In a private hub-and-spoke setup (single relay), it's usually best to keep clients on "dhtclient" and let only
      #   the relay (or a small set of infra nodes) serve the DHT.
      result = self.__run_command(
        ["ipfs", "config", "--json", "Routing.Type", '"dhtclient"']
      )
      return result

    def __disable_ws_transport(self):
      result = self.__run_command(
        ["ipfs", "config", "--json", "Swarm.Transports.Network.Websocket", "false"]
      )
      return result

    def __disable_mdns_discovery(self):
      # Discovery.MDNS.Enabled: enabled vs disabled
      # mDNS is "LAN auto-discovery": peers on the same local network broadcast/announce themselves and auto-connect.
      # - Enabled: convenient for dev/LAN clusters where multicast works and you want plug-and-play discovery.
      # - Disabled: recommended for most server/relay/container deployments across machines/subnets (mDNS doesn't cross
      #   routers reliably, adds background chatter, and creates less predictable connections). In hub-and-spoke networks
      #   with explicit bootstrap-to-relay, mDNS usually provides little value and can be turned off.
      result = self.__run_command(
        ["ipfs", "config", "--json", "Discovery.MDNS.Enabled", "false"]
      )
      return result

    def __set_reprovider_strategy(self):
      # Reprovider.Strategy: "all" vs "pinned" vs "pinned+mfs"
      # Controls what content this node periodically re-announces ("reprovides") into the DHT (CID → provider records).
      # - "all": advertise *everything* in the local blockstore (includes transient cache) → can be very expensive/noisy.
      # - "pinned": advertise only content explicitly pinned (the data you intend to keep/serve) → usually the best default.
      # - "pinned+mfs": advertise pinned content plus the current MFS tree (IPFS's mutable filesystem workspace) → use if
      #   your app relies on MFS state being discoverable. Prefer avoiding "all" unless you truly want to advertise cache.
      result = self.__run_command(
        ["ipfs", "config", "--json", "Reprovider.Strategy", f'"{IPFSCt.REPROVIDER_STRATEGY}"']
      )
      return result

    def __bootstrap_add(self, ipfs_relay):
      result = self.__run_command(
        ["ipfs", "bootstrap", "add", ipfs_relay]
      )
      return result

    def __bootstrap_remove(self, multiaddr):
      """
      Remove a bootstrap multiaddr when present.

      Parameters
      ----------
      multiaddr : str
          Bootstrap peer multiaddr to remove.

      Returns
      -------
      bool
          ``True`` when the remove command reported success.
      """
      output, errors = self.__run_command(
        ["ipfs", "bootstrap", "rm", multiaddr],
        raise_on_error=False,
        return_errors=True,
      )
      combined = " ".join(part for part in (output, errors) if part).lower()
      return "removed" in combined or "deleted" in combined

    def __set_config_json(self, key, value):
      """
      Set a JSON-valued IPFS config key.

      Parameters
      ----------
      key : str
          IPFS config key path understood by ``ipfs config --json``.
      value : any
          JSON-serializable value to persist.

      Returns
      -------
      str
          Raw stdout returned by the ``ipfs config`` command.
      """
      return self.__run_command(
        ["ipfs", "config", "--json", key, json.dumps(value, separators=(",", ":"))]
      )

    def __get_config_json(self, key, default=None):
      """
      Read and decode a JSON-valued IPFS config key.

      Parameters
      ----------
      key : str
          IPFS config key path understood by ``ipfs config --json``.
      default : any, optional
          Value returned when the key cannot be read or parsed.

      Returns
      -------
      any
          Decoded JSON value or ``default`` when reading/parsing fails.
      """
      if default is None:
        default = []
      output = self.__run_command(
        ["ipfs", "config", "--json", key],
        raise_on_error=False,
        show_logs=False,
      )
      if not output:
        return list(default) if isinstance(default, list) else default
      try:
        parsed = json.loads(output)
        if parsed is None:
          return list(default) if isinstance(default, list) else default
        return parsed
      except Exception:
        self.P(f"Failed to parse config key {key}: {output}", color='r')
        return list(default) if isinstance(default, list) else default

    def __get_bootstrap_list(self):
      """
      Return the current bootstrap peer list.

      Returns
      -------
      list[str]
          Bootstrap multiaddrs currently configured in the local IPFS repo.
      """
      output = self.__run_command(
        ["ipfs", "bootstrap", "list"],
        raise_on_error=False,
        show_logs=False,
      )
      if not output:
        return []
      return [line.strip() for line in output.splitlines() if line.strip()]

    def __ensure_bootstrap_entry(self, multiaddr):
      """
      Ensure a bootstrap multiaddr exists exactly once.

      Parameters
      ----------
      multiaddr : str
          Bootstrap peer multiaddr to add when missing.

      Returns
      -------
      bool
          ``True`` when the bootstrap entry was added, ``False`` when it was
          already present.
      """
      bootstraps = self.__get_bootstrap_list()
      if multiaddr in bootstraps:
        return False
      self.__bootstrap_add(multiaddr)
      return True

    def __merge_unique_values(self, existing, additions):
      """
      Append missing values while preserving original order.

      Parameters
      ----------
      existing : list
          Existing ordered values.
      additions : list
          Candidate values to append when not already present.

      Returns
      -------
      tuple[list, bool]
          Updated ordered list and a flag indicating whether any new value was
          appended.
      """
      merged = list(existing or [])
      changed = False
      for value in additions or []:
        if value and value not in merged:
          merged.append(value)
          changed = True
      return merged, changed

    def __parse_multiaddr(self, multiaddr):
      """
      Extract the relay addressing fields needed by the workaround.

      Parameters
      ----------
      multiaddr : str
          Relay multiaddr such as ``/ip4/<ip>/tcp/<port>/p2p/<peer_id>``.

      Returns
      -------
      dict or None
          Parsed relay metadata or ``None`` when the input does not contain the
          minimum fields required by the workaround.
      """
      if not multiaddr or not isinstance(multiaddr, str):
        return None
      parts = [part for part in multiaddr.split("/") if part]
      parsed = {
        "multiaddr": multiaddr,
        "ip4": None,
        "ip6": None,
        "tcp_port": None,
        "peer_id": None,
      }
      idx = 0
      while idx < len(parts):
        token = parts[idx]
        next_part = parts[idx + 1] if idx + 1 < len(parts) else None
        if token == "ip4":
          parsed["ip4"] = next_part
          idx += 2
          continue
        if token == "ip6":
          parsed["ip6"] = next_part
          idx += 2
          continue
        if token == "tcp":
          parsed["tcp_port"] = next_part
          idx += 2
          continue
        if token in {"p2p", "ipfs"}:
          parsed["peer_id"] = next_part
          idx += 2
          continue
        idx += 1
      if not parsed["tcp_port"] or not parsed["peer_id"]:
        return None
      return parsed

    def __build_peer_multiaddr(self, ip_version, host, port, peer_id):
      """
      Build a peer multiaddr from normalized parts.

      Parameters
      ----------
      ip_version : str
          Multiaddr IP token, usually ``ip4`` or ``ip6``.
      host : str
          Peer host or IP literal.
      port : str
          TCP port.
      peer_id : str
          IPFS peer id.

      Returns
      -------
      str
          Peer multiaddr.
      """
      return f"/{ip_version}/{host}/tcp/{port}/p2p/{peer_id}"

    def __samehost_fix_enabled(self):
      """
      Determine whether the same-host workaround should run.

      Returns
      -------
      bool
          ``True`` only when the environment explicitly enables the workaround.
          The fix is currently opt-in because the local same-host path can pass
          control-plane checks while still failing block transfer.
      """
      value = os.getenv(IPFSCt.EE_R1FS_SAMEHOST_RELAY_FIX)
      if value is None:
        return False
      return str(value).strip().lower() in {"1", "true", "yes", "on"}

    def __get_default_gateway(self):
      """
      Discover the container default gateway from ``ip route``.

      Returns
      -------
      str or None
          Default gateway IP string or ``None`` when it cannot be determined.
      """
      output = self.__run_command(
        ["ip", "route"],
        raise_on_error=False,
        show_logs=False,
        timeout=5,
      )
      if not output:
        return None
      for line in output.splitlines():
        parts = line.strip().split()
        if len(parts) >= 3 and parts[0] == "default":
          try:
            via_idx = parts.index("via")
            return parts[via_idx + 1]
          except Exception:
            continue
      return None

    def __build_addr_filters(self, relay_info):
      """
      Build addrfilter entries that block the relay public addresses.

      Parameters
      ----------
      relay_info : dict
          Parsed relay metadata from :meth:`__parse_multiaddr`.

      Returns
      -------
      list[str]
          Addrfilter entries for the relay public IPs.
      """
      filters = []
      if relay_info.get("ip4"):
        filters.append(f"/ip4/{relay_info['ip4']}/ipcidr/32")
      if relay_info.get("ip6"):
        filters.append(f"/ip6/{relay_info['ip6']}/ipcidr/128")
      return filters

    def __has_all_values(self, existing, required):
      """
      Check whether every required value is already present.

      Parameters
      ----------
      existing : list
          Existing values.
      required : list
          Values that must be present.

      Returns
      -------
      bool
          ``True`` when all required values are already contained in
          ``existing``.
      """
      existing_values = set(existing or [])
      return all(value in existing_values for value in (required or []) if value)

    def __get_samehost_fix_marker_path(self):
      """
      Return the marker path used by the same-host workaround.

      Returns
      -------
      str
          Absolute marker path inside the active IPFS repo.
      """
      return os.path.join(self.__ipfs_home, IPFSCt.SAMEHOST_FIX_MARKER)

    def __read_samehost_fix_marker(self):
      """
      Read the persisted same-host workaround marker.

      Returns
      -------
      dict or None
          Marker payload or ``None`` when the marker is missing or invalid.
      """
      marker_path = self.__get_samehost_fix_marker_path()
      if not os.path.isfile(marker_path):
        return None
      try:
        with open(marker_path, "r") as fd:
          return json.load(fd)
      except Exception as e:
        self.P(f"Failed to read same-host relay marker: {e}", color='r')
        return None

    def __write_samehost_fix_marker(self, marker):
      """
      Persist the same-host workaround marker.

      Parameters
      ----------
      marker : dict
          Marker payload describing the last successful or cached-negative
          same-host decision.
      """
      marker_path = self.__get_samehost_fix_marker_path()
      with open(marker_path, "w") as fd:
        json.dump(marker, fd, indent=2, sort_keys=True)

    def __delete_samehost_fix_marker(self):
      """
      Remove the persisted same-host workaround marker when present.
      """
      marker_path = self.__get_samehost_fix_marker_path()
      if os.path.isfile(marker_path):
        os.remove(marker_path)
      return

    def __marker_matches_samehost_fix(self, marker, relay_info, gateway):
      """
      Check whether a marker describes the current relay/gateway tuple.

      Parameters
      ----------
      marker : dict
          Persisted marker payload.
      relay_info : dict
          Parsed relay metadata from :meth:`__parse_multiaddr`.
      gateway : str
          Current container default gateway.

      Returns
      -------
      bool
          ``True`` when the marker matches the current same-host decision
          inputs.
      """
      if not isinstance(marker, dict):
        return False
      return (
        marker.get("peer_id") == relay_info.get("peer_id") and
        marker.get("relay_ip4") == relay_info.get("ip4") and
        marker.get("relay_ip6") == relay_info.get("ip6") and
        marker.get("gateway") == gateway
      )

    def __get_marker_status(self, marker):
      """
      Return the stored marker status.

      Parameters
      ----------
      marker : dict
          Persisted marker payload.

      Returns
      -------
      str or None
          Marker status or ``None`` when unavailable.
      """
      if not isinstance(marker, dict):
        return None
      return marker.get("status")

    def __parse_marker_timestamp(self, marker):
      """
      Parse the marker timestamp as an aware UTC datetime.

      Parameters
      ----------
      marker : dict
          Persisted marker payload.

      Returns
      -------
      datetime or None
          Parsed timestamp or ``None`` when missing/invalid.
      """
      if not isinstance(marker, dict):
        return None
      timestamp = marker.get("timestamp")
      if not timestamp:
        return None
      try:
        return datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
      except Exception:
        return None

    def __is_negative_marker_fresh(self, marker):
      """
      Check whether a cached negative proof result is still usable.

      Parameters
      ----------
      marker : dict
          Persisted marker payload.

      Returns
      -------
      bool
          ``True`` when the marker represents a recent failed proof for the
          current relay/gateway tuple.
      """
      if self.__get_marker_status(marker) != "negative":
        return False
      marker_time = self.__parse_marker_timestamp(marker)
      if marker_time is None:
        return False
      age_seconds = (datetime.now(timezone.utc) - marker_time).total_seconds()
      return age_seconds >= 0 and age_seconds < IPFSCt.SAMEHOST_FIX_NEGATIVE_TTL_SECONDS

    def __swarm_connect(self, multiaddr, timeout=IPFSCt.TIMEOUT):
      """
      Attempt an ``ipfs swarm connect`` and normalize success detection.

      Parameters
      ----------
      multiaddr : str
          Peer multiaddr to connect to.
      timeout : int, optional
          Command timeout in seconds.

      Returns
      -------
      tuple[bool, str, str]
          Success flag, stdout, and stderr from the command.
      """
      output, errors = self.__run_command(
        ["ipfs", "swarm", "connect", multiaddr],
        raise_on_error=False,
        return_errors=True,
        timeout=timeout,
      )
      combined = " ".join(part for part in (output, errors) if part).lower()
      success = "success" in combined or "already connected" in combined
      return success, output, errors

    def __swarm_disconnect(self, target, timeout=10):
      """
      Attempt an ``ipfs swarm disconnect`` without treating failures as fatal.

      Parameters
      ----------
      target : str
          Peer multiaddr or peer id to disconnect from.
      timeout : int, optional
          Command timeout in seconds.

      Returns
      -------
      tuple[bool, str, str]
          Success flag, stdout, and stderr from the command.
      """
      output, errors = self.__run_command(
        ["ipfs", "swarm", "disconnect", target],
        raise_on_error=False,
        return_errors=True,
        timeout=timeout,
      )
      combined = " ".join(part for part in (output, errors) if part).lower()
      success = "disconnect" in combined or "success" in combined or "not connected" in combined
      return success, output, errors

    def __peer_uses_multiaddr(self, peer_lines, multiaddr):
      """
      Check whether a peer is currently connected through an exact multiaddr.

      Parameters
      ----------
      peer_lines : list[str]
          Output lines from ``ipfs swarm peers``.
      multiaddr : str
          Expected peer multiaddr.

      Returns
      -------
      bool
          ``True`` when the exact multiaddr is present in ``peer_lines``.
      """
      return multiaddr in (peer_lines or [])

    def __wait_for_peer_disconnect(self, peer_id, timeout=5, step=0.5):
      """
      Wait until a peer id disappears from ``ipfs swarm peers``.

      Parameters
      ----------
      peer_id : str
          Peer id to wait for.
      timeout : int, optional
          Maximum wait in seconds.
      step : float, optional
          Poll interval in seconds.

      Returns
      -------
      bool
          ``True`` when the peer is no longer present in swarm peers.
      """
      waited = 0.0
      while waited < timeout:
        peer_lines = self._get_swarm_peers()
        if not any(peer_id in line for line in peer_lines):
          return True
        time.sleep(step)
        waited += step
      return False

    def __prove_samehost_relay_path(self, relay_info, local_multiaddr):
      """
      Prove that the relay is reachable through the local gateway multiaddr.

      The proof must show an active swarm peer connection on the exact local
      multiaddr after disconnecting any existing relay connection. This avoids
      accepting a stale/public relay connection as evidence that the local path
      works.

      Parameters
      ----------
      relay_info : dict
          Parsed relay metadata from :meth:`__parse_multiaddr`.
      local_multiaddr : str
          Candidate local-gateway relay multiaddr.

      Returns
      -------
      tuple[bool, str]
          Proof success flag and a short diagnostic message.
      """
      peer_id = relay_info["peer_id"]
      public_multiaddrs = []
      if relay_info.get("ip4"):
        public_multiaddrs.append(self.__build_peer_multiaddr("ip4", relay_info["ip4"], relay_info["tcp_port"], peer_id))
      if relay_info.get("ip6"):
        public_multiaddrs.append(self.__build_peer_multiaddr("ip6", relay_info["ip6"], relay_info["tcp_port"], peer_id))

      # Disconnect any existing relay session so a subsequent success must come
      # from the specific local multiaddr under test.
      for public_multiaddr in public_multiaddrs:
        self.__swarm_disconnect(public_multiaddr, timeout=5)
      self.__swarm_disconnect(local_multiaddr, timeout=5)
      self.__wait_for_peer_disconnect(peer_id, timeout=5, step=0.5)

      self.P(
        f"Same-host relay check: probing local gateway path {local_multiaddr} for relay peer {peer_id}.",
        color='d',
      )
      success, output, errors = self.__swarm_connect(local_multiaddr, timeout=5)
      if not success:
        reconnect_target = public_multiaddrs[0] if public_multiaddrs else self.__ipfs_relay
        self.__swarm_connect(reconnect_target, timeout=5)
        return False, errors or output or "local connect failed"

      peer_lines = self._get_swarm_peers()
      if self.__peer_uses_multiaddr(peer_lines, local_multiaddr):
        self.P(f"Same-host relay check: relay peer confirmed on local path {local_multiaddr}.", color='g')
        return True, "connected through local multiaddr"

      reconnect_target = public_multiaddrs[0] if public_multiaddrs else self.__ipfs_relay
      for public_multiaddr in public_multiaddrs:
        self.__swarm_disconnect(public_multiaddr, timeout=5)
      self.__swarm_disconnect(local_multiaddr, timeout=5)
      self.__swarm_connect(reconnect_target, timeout=5)
      return False, "relay peer did not reappear on the expected local multiaddr"

    def __reset_connection_state(self):
      """
      Clear connection-derived state before a daemon restart.
      """
      self.__ipfs_started = False
      self.__connected_at = None
      self.__peers = []

    def __shutdown_daemon(self, timeout=20):
      """
      Stop the local IPFS daemon.

      Parameters
      ----------
      timeout : int, optional
          Grace period in seconds before falling back to signaling daemon PIDs.

      Returns
      -------
      bool
          ``True`` when the daemon is observed stopped.
      """
      if not self.is_ipfs_daemon_running():
        return True
      self.__run_command(
        ["ipfs", "shutdown"],
        raise_on_error=False,
        timeout=10,
      )
      waited = 0
      while waited < timeout:
        if not self.is_ipfs_daemon_running():
          return True
        time.sleep(1)
        waited += 1

      pid_output = self.__run_command(
        ["pgrep", "-f", "ipfs daemon"],
        raise_on_error=False,
        show_logs=False,
        timeout=5,
      )
      if pid_output:
        for pid_text in pid_output.splitlines():
          try:
            os.kill(int(pid_text.strip()), signal.SIGINT)
          except Exception:
            continue
      waited = 0
      while waited < 5:
        if not self.is_ipfs_daemon_running():
          return True
        time.sleep(1)
        waited += 1
      return not self.is_ipfs_daemon_running()

    def __start_daemon(self, max_attempts=10, sleep_time=2):
      """
      Start the local IPFS daemon and wait for its API to respond.

      Parameters
      ----------
      max_attempts : int, optional
          Number of health-check attempts.
      sleep_time : int, optional
          Delay in seconds between health checks.

      Returns
      -------
      bool
          ``True`` when the daemon becomes reachable.
      """
      self.P("Starting IPFS daemon in background...")
      subprocess.Popen(
        ["ipfs", "daemon", "--enable-gc", "--migrate=true"],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
      )
      for attempt in range(max_attempts):
        ipfs_daemon_running = self.is_ipfs_daemon_running()
        if ipfs_daemon_running:
          self.P("IPFS daemon started successfully.", color='g')
          return True
        self.P(f"Check {attempt + 1}/{max_attempts} IPFS running: {ipfs_daemon_running}", color='r')
        time.sleep(sleep_time)
      return False

    def __restart_daemon(self):
      """
      Restart the local IPFS daemon after config changes.

      Returns
      -------
      bool
          ``True`` when the daemon was stopped and started successfully.
      """
      self.P("Restarting IPFS daemon to apply same-host relay workaround...", color='m')
      self.__reset_connection_state()
      if not self.__shutdown_daemon():
        self.P("Failed to stop IPFS daemon cleanly.", color='r')
        return False
      if not self.__start_daemon():
        self.P("Failed to restart IPFS daemon after applying workaround.", color='r')
        return False
      return True

    def __cleanup_samehost_relay_workaround(self):
      """
      Remove previously applied same-host workaround state from the repo.

      Returns
      -------
      bool
          ``True`` when repo config was changed, otherwise ``False``.
      """
      relay_info = self.__parse_multiaddr(self.__ipfs_relay)
      if relay_info is None:
        return False

      gateway = self.__get_default_gateway()
      local_multiaddr = None
      if gateway:
        try:
          ipaddress.ip_address(gateway)
          local_multiaddr = self.__build_peer_multiaddr(
            "ip4",
            gateway,
            relay_info["tcp_port"],
            relay_info["peer_id"],
          )
        except ValueError:
          local_multiaddr = None

      changed = False
      if local_multiaddr and local_multiaddr in self.__get_bootstrap_list():
        if self.__bootstrap_remove(local_multiaddr):
          self.P(f"Same-host relay workaround cleanup: removed local bootstrap {local_multiaddr}.", color='g')
          changed = True

      addr_filters = self.__get_config_json("Swarm.AddrFilters", default=[])
      desired_filters = set(self.__build_addr_filters(relay_info))
      cleaned_filters = [value for value in addr_filters if value not in desired_filters]
      if cleaned_filters != addr_filters:
        self.__set_config_json("Swarm.AddrFilters", cleaned_filters)
        self.P("Same-host relay workaround cleanup: removed local-path addrfilters from repo config.", color='g')
        changed = True

      marker = self.__read_samehost_fix_marker()
      if marker is not None:
        self.__delete_samehost_fix_marker()
        self.Pd("Same-host relay workaround cleanup: removed persisted marker.")

      return changed

    def __maybe_apply_samehost_relay_workaround(self):
      """
      Reconcile same-host relay bootstrap/filter state.

      The workaround is intended for edge-node containers running on the same
      host as the relay. It uses three progressively more expensive checks:

      1. Marker + live config fast path.
      2. Bounded negative-cache skip for recent failed proofs.
      3. Proof connect to the container gateway before mutating config.

      Returns
      -------
      bool
          ``True`` when config changes required a daemon restart, otherwise
          ``False``.
      """
      if self.__samehost_fix_attempted:
        return False
      self.__samehost_fix_attempted = True

      if not self.__samehost_fix_enabled():
        self.Pd("Same-host relay workaround: disabled by environment.")
        self.__cleanup_samehost_relay_workaround()
        return False

      relay_info = self.__parse_multiaddr(self.__ipfs_relay)
      if relay_info is None:
        self.P("Same-host relay workaround: skipped because relay multiaddr could not be parsed.", color='r')
        return False

      gateway = self.__get_default_gateway()
      if not gateway:
        self.P("Same-host relay workaround: skipped because the container gateway could not be determined.", color='r')
        return False

      try:
        ipaddress.ip_address(gateway)
      except ValueError:
        self.P(f"Same-host relay workaround: skipped because gateway {gateway} is invalid.", color='r')
        return False

      local_multiaddr = self.__build_peer_multiaddr(
        "ip4",
        gateway,
        relay_info["tcp_port"],
        relay_info["peer_id"],
      )

      marker = self.__read_samehost_fix_marker()
      marker_matches = self.__marker_matches_samehost_fix(marker, relay_info, gateway)
      addr_filters = self.__get_config_json("Swarm.AddrFilters", default=[])
      desired_filters = self.__build_addr_filters(relay_info)
      bootstraps = self.__get_bootstrap_list()
      bootstrap_matches = local_multiaddr in bootstraps
      filters_match = self.__has_all_values(addr_filters, desired_filters)
      # Fast path: when both marker and repo config already match the desired
      # same-host state, avoid any extra proof-connect or restart work.
      if marker_matches and bootstrap_matches and filters_match:
        self.Pd("Same-host relay workaround: marker and repo config already match; nothing to do.")
        return False
      # Negative cache: off-host nodes can repeatedly fail the local gateway
      # proof; cache that result for a short TTL to avoid paying the timeout on
      # every process restart.
      if marker_matches and self.__is_negative_marker_fresh(marker):
        self.Pd("Same-host relay workaround: recent negative proof cached for this relay/gateway; skipping probe.")
        return False

      success, proof_reason = self.__prove_samehost_relay_path(relay_info, local_multiaddr)
      if not success:
        self.__write_samehost_fix_marker({
          "peer_id": relay_info.get("peer_id"),
          "relay_ip4": relay_info.get("ip4"),
          "relay_ip6": relay_info.get("ip6"),
          "gateway": gateway,
          "local_multiaddr": local_multiaddr,
          "status": "negative",
          "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
        })
        self.P(f"Same-host relay workaround: local gateway path not usable ({proof_reason}); keeping public relay path.", color='d')
        return False

      local_bootstrap_changed = False
      if not bootstrap_matches:
        local_bootstrap_changed = self.__ensure_bootstrap_entry(local_multiaddr)
      merged_filters, filters_changed = self.__merge_unique_values(addr_filters, desired_filters)
      if filters_changed:
        self.__set_config_json("Swarm.AddrFilters", merged_filters)

      new_marker = {
        "peer_id": relay_info.get("peer_id"),
        "relay_ip4": relay_info.get("ip4"),
        "relay_ip6": relay_info.get("ip6"),
        "gateway": gateway,
        "local_multiaddr": local_multiaddr,
        "status": "applied",
        "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
      }
      if not marker_matches or filters_changed:
        self.__write_samehost_fix_marker(new_marker)

      if not filters_changed:
        if local_bootstrap_changed:
          self.P(f"Same-host relay workaround: restored local bootstrap {local_multiaddr}; restart not required.", color='g')
        else:
          self.Pd("Same-host relay workaround: proof succeeded but config changes were already present.")
        return False

      self.P(
        "Same-host relay workaround: applying addrfilters {} and restarting daemon.".format(
          json.dumps(desired_filters)
        ),
        color='m',
      )
      return self.__restart_daemon()

    # PUBLIC COMMAND
    def get_ipfs_id_data(self) -> dict:
      """
      Get the IPFS peer ID via 'ipfs id' (JSON output).
      Returns the ipfs ID object.
      """
      data = {}
      output = self.__run_command(["ipfs", "id"])  # this will raise an exception if the command fails
      try:
        data = json.loads(output)
      except json.JSONDecodeError:
        raise Exception("Failed to parse JSON from 'ipfs id' output.")
      except Exception as e:
        msg = f"Error getting IPFS ID: {e}. `ipfs id`:\n{data}"
        self.P(msg, color='r')
        raise Exception(f"Error getting IPFS ID: {e}") from e
      return data

    # END PUBLIC COMMANS

    def __run_command(
      self, 
      cmd_list: list, 
      raise_on_error=True,
      timeout=IPFSCt.TIMEOUT,
      verbose=False,
      return_errors=False,
      show_logs=True,
    ):
      """
      Run a shell command using subprocess.run with a timeout.
      Logs the command and its result. If verbose is enabled,
      prints command details. Raises an exception on error if raise_on_error is True.
      """
      failed = False
      output = ""
      errors = ""
      cmd_str = " ".join(cmd_list)
      if show_logs:
        self.Pd(f"Running command: {cmd_str}", color='d')
      try:
        result = subprocess.run(
          cmd_list, 
          capture_output=True, 
          text=True, 
          timeout=timeout,
        )
      except subprocess.TimeoutExpired as e:
        failed = True
        if show_logs:
          self.P(f"Command timed out after {timeout} seconds: {cmd_str}", color='r')
        if raise_on_error:
          raise Exception(f"Timeout expired for '{cmd_str}'") from e
      except Exception as e:
        failed = True
        msg = f"Error running command `{cmd_str}`: {e}"
        if show_logs:
          self.P(msg, color='r')
        if raise_on_error:
          raise Exception(msg) from e
      
      if result.returncode != 0:
        errors = result.stderr.strip()
        failed = True
        if show_logs:
          self.P(f"Command error `{cmd_str}`: {errors}", color='r')
        if raise_on_error:
          raise Exception(f"Error while running '{cmd_str}': {result.stderr.strip()}")
      
      if not failed:
        if show_logs:
          if verbose:
            self.Pd(f"Command output: {result.stdout.strip()}")
        output = result.stdout.strip()
      if return_errors:
        return output, errors
      return output
    

    def __get_id(self) -> str:
      """
      Get the IPFS peer ID via 'ipfs id' (JSON output).
      Returns the 'ID' field as a string.
      """
      data = self.get_ipfs_id_data()
      self.__ipfs_id_result = data
      self.__ipfs_id = data.get("ID", ERROR_TAG)
      self.__ipfs_agent = data.get("AgentVersion", ERROR_TAG)
      addrs = data.get("Addresses", [])
      if not addrs:
        self.__ipfs_address = None
      else:
        self.__ipfs_address = addrs[1] if len(addrs) > 1 else addrs[0] if len(addrs) else ERROR_TAG
      return self.__ipfs_id
    


    @require_ipfs_started
    def __pin_add(self, cid: str) -> str:
      """
      Explicitly pin a CID (and fetch its data) so it appears in the local pinset.
      """
      res = self.__run_command(["ipfs", "pin", "add", cid])
      self.Pd(f"{res}")
      return res  

  # Public R1FS API calls
  if True:
 
    def add_json(
      self, 
      data, 
      fn=None, 
      secret: str = None,
      nonce: int = None,
      use_tempfile=False,
      show_logs=True,
      raise_on_error=False,
    ) -> str:
      """
      Add a JSON object to IPFS.
      
      Parameters
      ----------
      data : any
          JSON-serializable data to add to IPFS.
          
      fn : str, optional
          Filename to use for the JSON data. If None, uses DEFAULT_FILENAME_JSON
          in a unique temporary directory.
          
      secret : str, optional
          Passphrase for AES-GCM encryption. Defaults to 'ratio1'.
          
      nonce : int, optional
          Nonce for encryption. If None, a random nonce will be generated.
          
      use_tempfile : bool, optional
          If True, use a system temporary file with random name. If False, use specified filename in unique directory.
          
      show_logs : bool, optional
          Whether to show logs via self.P / self.Pd. Default is True.
          
      raise_on_error : bool, optional
          If True, raise an Exception on command errors. Otherwise, logs them. Default is False.
          
      Returns
      -------
      str
          The CID of the added JSON file.
      """
      unique_dir = None
      try:
        json_data = json.dumps(data, sort_keys=True, separators=(',', ':'))
        self.P(f"JSON data: {json_data}")
        
        if use_tempfile:
          if show_logs:
            self.Pd("Using tempfile for JSON")
          with tempfile.NamedTemporaryFile(
            mode='w', suffix='.json', delete=False
          ) as f:
            f.write(json_data)
          fn = f.name
        else:
          # Use default filename if none specified
          if fn is None:
            fn = DEFAULT_FILENAME_JSON
            
          # Create unique temporary directory for the file
          unique_dir = os.path.join(tempfile.gettempdir(), uuid.uuid4().hex)
          os.makedirs(unique_dir, exist_ok=True)
          fn = os.path.join(unique_dir, fn)
          
          if show_logs:
            self.Pd(f"Using unique directory for JSON: {fn}")
          with open(fn, "w") as f:
            f.write(json_data)
        #end if tempfile
        
        if show_logs:
          self.Pd(f"About to call add_file with: file_path={fn}, nonce={nonce}, secret={secret[:10] if secret else 'None'}...")
        
        cid = self.add_file(
          file_path=fn,
          secret=secret,
          nonce=nonce,
          show_logs=show_logs,
          raise_on_error=raise_on_error
        )
        
        if show_logs:
          self.Pd(f"add_file returned CID: {cid}")
        
        return cid
        
      except Exception as e:
        if show_logs:
          self.P(f"Error adding JSON to IPFS: {e}", color='r')
        if raise_on_error:
          raise
        return None
        
      finally:
        # Clean up temporary files and directories
        if fn and os.path.exists(fn):
          os.remove(fn)
          if show_logs:
            self.Pd(f"Cleaned up temporary JSON file: {fn}")
            
        if unique_dir and os.path.exists(unique_dir):
          os.rmdir(unique_dir)
          if show_logs:
            self.Pd(f"Cleaned up temporary directory: {unique_dir}")
      
      
    def add_yaml(
      self, 
      data, 
      fn=None, 
      secret: str = None,
      nonce: int = None,
      tempfile=False,
      show_logs=True,
      raise_on_error=False,
    ) -> str:
      """
      Add a YAML object to IPFS.
      """
      try:
        import yaml
        yaml_data = yaml.dump(data)
        if tempfile:
          if show_logs:
            self.Pd("Using tempfile for YAML")
          with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
            f.write(yaml_data)
          fn = f.name
        else:
          fn = self._get_unique_or_complete_upload_name(fn=fn, suffix=".yaml")
          if show_logs:
            self.Pd(f"Using unique name for YAML: {fn}")
          with open(fn, "w") as f:
            f.write(yaml_data)
        cid = self.add_file(
          file_path=fn,
          secret=secret,
          nonce=nonce,
          show_logs=show_logs,
          raise_on_error=raise_on_error
        )
        return cid
      except Exception as e:
        if show_logs:
          self.P(f"Error adding YAML to IPFS: {e}", color='r')
        return None
      
      
    def add_pickle(
      self, 
      data, 
      fn=None, 
      secret: str = None,
      nonce: int = None,
      use_tempfile=False,
      show_logs=True,
      raise_on_error=False,
    ) -> str:
      """
      Add a Pickle object to IPFS.
      
      Parameters
      ----------
      data : any
          Python object to pickle and add to IPFS.
          
      fn : str, optional
          Filename to use for the pickle data. If None, generates a unique filename.
          
      secret : str, optional
          Passphrase for AES-GCM encryption. Defaults to 'ratio1'.
          
      nonce : int, optional
          Nonce for encryption. If None, a random nonce will be generated.
          
      use_tempfile : bool, optional
          If True, use a system temporary file with random name. If False, use specified filename.
          
      show_logs : bool, optional
          Whether to show logs via self.P / self.Pd. Default is True.
          
      raise_on_error : bool, optional
          If True, raise an Exception on command errors. Otherwise, logs them. Default is False.
          
      Returns
      -------
      str
          The CID of the added pickle file.
      """
      try:
        import pickle
        if show_logs:
          self.Pd(f"Pickling data of type: {type(data)}")
        
        if use_tempfile:
          if show_logs:
            self.Pd("Using tempfile for Pickle")
          with tempfile.NamedTemporaryFile(mode='wb', suffix='.pkl', delete=False) as f:
            pickle.dump(data, f)
          fn = f.name
        else:
          if fn is None:
            fn = self._get_unique_or_complete_upload_name(fn=fn, suffix=".pkl")
            if show_logs:
              self.Pd(f"Using unique name for pkl: {fn}")
          else:
            if show_logs:
              self.Pd(f"Using provided filename: {fn}")
          with open(fn, "wb") as f:
            pickle.dump(data, f)
        
        if show_logs:
          self.Pd(f"About to call add_file with: file_path={fn}, nonce={nonce}, secret={secret[:10] if secret else 'None'}...")
        
        cid = self.add_file(
          file_path=fn,
          secret=secret,
          nonce=nonce,
          show_logs=show_logs,
          raise_on_error=raise_on_error
        )
        
        if show_logs:
          self.Pd(f"add_file returned CID: {cid}")
        
        return cid
        
      except Exception as e:
        if show_logs:
          self.P(f"Error adding Pickle to IPFS: {e}", color='r')
        if raise_on_error:
          raise
        return None
        
      finally:
        # Clean up temporary files
        if 'fn' in locals() and fn and os.path.exists(fn):
          os.remove(fn)
          if show_logs:
            self.Pd(f"Cleaned up temporary pickle file: {fn}")


    @require_ipfs_started
    def add_file(
      self,
      file_path: str,
      nonce: int = None,
      secret: str = None,
      raise_on_error: bool = False,
      show_logs: bool = True,
    ) -> str:
      """
      Add a file to R1FS with default encryption. The secret parameter is mandatory,
      defaulting to 'ratio1'. Each encryption run is chunked for large files, and
      the original filename is stored in JSON metadata.


      Parameters
      ----------
      file_path : str
        Path to the local plaintext file.

      secret : str, optional
        Mandatory passphrase, defaulting to 'ratio1'. Must not be empty.
        
      raise_on_error : bool, optional
        If True, raise an Exception on command errors. Otherwise logs them. Default is False.

      show_logs : bool, optional
        Whether to show logs via self.P / self.Pd. Default is True.

      Returns
      -------
      str
        The folder CID of the wrapped IPFS directory containing the ciphertext.

      Raises
      ------
      FileNotFoundError
        If file_path does not exist.

      ValueError
        If secret is empty.

      RuntimeError
        If the 'ipfs add' command yields no output.

      Examples
      --------
      >>> cid = engine.add_file("/data/large_model.bin")
      >>> print(cid)
      QmFolder123ABC
      """
      add_time, remove_time, pin_time = 0.0, 0.0, 0.0
      start_time = time.time()

      if secret in ["", None]:
        secret = self.__DEFAULT_SECRET
      
      if not os.path.isfile(file_path):
        raise FileNotFoundError(f"File not found: {file_path}")

      # Check file size and throw an error if larger than 5 GB.
      file_size = os.path.getsize(file_path)
      if file_size > 5 * 1024 * 1024 * 1024:
        raise ValueError(f"File {file_path} is too large ({file_size} bytes). Maximum allowed size is 2 GB.")

      key = self._hash_secret(secret)  # mandatory passphrase

      if nonce is None:
        nonce_bytes = os.urandom(12)           # recommended for GCM
        if show_logs:
          self.Pd(f"Generated random nonce: {nonce_bytes.hex()}")
      else:
        original_nonce = nonce
        nonce_bytes = random.Random(nonce).randbytes(12)

      original_basename = os.path.basename(file_path)

      # JSON metadata storing the original filename
      meta_dict = {"filename": original_basename}
      meta_bytes = json.dumps(meta_dict).encode("utf-8")
      
      if show_logs:
        self.Pd(f"Original basename: {original_basename}")
        self.Pd(f"Metadata: {meta_dict}")
        self.Pd(f"Secret hash (first 16 bytes): {key[:16].hex()}")

      tmp_cipher_path = os.path.join(tempfile.gettempdir(), uuid.uuid4().hex + ".bin")
      
      folder_cid = None
      try:
        encryptor = Cipher(algorithms.AES(key), modes.GCM(nonce_bytes)).encryptor()

        chunk_size = 1024 * 1024  # 1 MB chunks
        with open(file_path, "rb") as fin, open(tmp_cipher_path, "wb") as fout:
          # [nonce][4-byte-len][metadata][ciphertext][16-byte GCM tag]
          fout.write(nonce_bytes)
          meta_len = len(meta_bytes)
          fout.write(meta_len.to_bytes(4, "big"))
          # encrypt the metadata and save
          enc_meta_data = encryptor.update(meta_bytes)
          fout.write(enc_meta_data)

          while True:
            chunk = fin.read(chunk_size)
            if not chunk:
              break
            fout.write(encryptor.update(chunk))
          #end while there are still bytes to read
          final_ct = encryptor.finalize()
          fout.write(final_ct)
          # Append 16-byte GCM tag
          tag = encryptor.tag
          fout.write(tag)
        #end with fin, fout

        # Now we IPFS-add the ciphertext
        output = self.__run_command(["ipfs", "add", "-q", "-w", tmp_cipher_path], show_logs=show_logs)
        lines = output.strip().split("\n")
        self.P("output lines: ", json.dumps(lines))

        if not lines:
          raise RuntimeError("No output from 'ipfs add -w -q' for ciphertext.")
        folder_cid = lines[-1].strip()
      except Exception as e:
        msg = f"Error encrypting file {file_path}: {e}"
        if raise_on_error:
          raise RuntimeError(msg)
        else:
          self.P(msg, color='r')
      #end try
      add_time = time.time() - start_time
      
      remove_start_time = time.time()
      # Cleanup temp file
      os.remove(tmp_cipher_path)
      remove_time = time.time() - remove_start_time
      
      if folder_cid is not None:
        # Now we notify the relay (if any) about the new CID
        pin_start_time = time.time()
        if self.__ipfs_relay_api is not None:
          #  Notifying the Relay about a new CID.
          try:
            request_url = f"{self.__ipfs_relay_api}/api/v0/pin/add?arg={folder_cid}"
            response = requests.post(request_url,
                                     auth=HTTPBasicAuth(self.__ipfs_api_key_username, self.__ipfs_api_key_password),
                                     verify=self.__ipfs_certificate_path)
            if response.status_code == 200:
              self.Pd(f"Relay successfully notified about CID={folder_cid}.")
            else:
              msg = f"Failed to notify relay about CID {folder_cid}: {response.text}"
              if raise_on_error:
                raise RuntimeError(msg)
              else:
                self.P(msg, color='r')
            #end if response status code
          except requests.RequestException as e:
            msg = f"Error notifying relay about CID {folder_cid}: {e}"
            if raise_on_error:
              raise RuntimeError(msg)
            else:
              self.P(msg, color='r')
          #end try

        self.__uploaded_files[folder_cid] = file_path
        # now we pin the folder
        res = self.__pin_add(folder_cid)
        pin_time = time.time() - pin_start_time
        #end if show_logs
      #end if folder_cid is not None
      total_time = time.time() - start_time
      if show_logs:
        self.P(
          f"Added file {file_path} as <{folder_cid}> in {total_time:.2f}s: add_time={add_time:.2f}s, remove_time={remove_time:.2f}s, pin_time={pin_time:.2f}s")
      return folder_cid


    @require_ipfs_started
    def get_file(
      self,
      cid: str,
      local_folder: str = None,
      secret: str = None,
      timeout: int = None,
      pin: bool = True,
      raise_on_error: bool = False,
      show_logs: bool = True,
      return_absolute_path: bool = True,
    ) -> str:
      """
      Retrieve an encrypted file from R1FS by CID, decrypt with AES-GCM in streaming mode.
      The secret parameter is mandatory (default 'ratio1'). The original filename is
      restored from JSON metadata.

      R1FS get can time out if it stalls. If the timeout is None, we use IPFSCt.TIMEOUT.

      Parameters
      ----------
      cid : str
        The folder CID (wrapped single file).

      local_folder : str, optional
        Destination folder. If None, we default to something like self.__downloads_dir/<CID>.

      secret : str, optional
        Passphrase for AES-GCM. Must not be empty. Defaults to 'ratio1'.

      timeout : int, optional
        Maximum seconds for the IPFS get. If None, use IPFSCt.TIMEOUT.

      pin : bool, optional
        If True, we optionally pin the folder. Default True.

      raise_on_error : bool, optional
        If True, raise an Exception on command errors/timeouts. Otherwise logs them. Default False.

      show_logs : bool, optional
        If True, logs steps via self.P / self.Pd. Default True.

      return_absolute_path : bool, optional
        If True, return the absolute path to the restored plaintext file.

      Returns
      -------
      str
        The full path to the restored plaintext file.

      Raises
      ------
      ValueError
        If the secret is empty.

      RuntimeError
        If multiple or zero files are found in the downloaded folder,
        or if we fail to parse the JSON metadata.

      Exception
        If the GCM tag is invalid or the IPFS command times out
        and raise_on_error=True.

      Examples
      --------
      >>> # Simple usage with default passphrase
      >>> local_file = engine.get_file("QmEncFolderXYZ")
      >>> print(local_file)
      /app/downloads/QmEncFolderXYZ/original_filename.bin
      
      """
      # Validate CID parameter
      if cid in [None, ""]:
        msg = "CID parameter cannot be None or empty"
        if raise_on_error:
          raise ValueError(msg)
        else:
          if show_logs:
            self.P(msg, color='r')
          return None
      
      if secret in ["", None]:
        secret = self.__DEFAULT_SECRET
        
      key = self._hash_secret(secret)

      if pin:
        try:
          pin_result = self.__pin_add(cid)
        except Exception as e:
          msg = f"Error pinning CID {cid}: {e}"
          if raise_on_error:
            raise RuntimeError(msg)
          else:
            self.P(msg, color='r')
            return None
          # end if
        #end try
      #end if pin

      if local_folder is None:
        local_folder = self.__downloads_dir # default downloads directory
      
      os.makedirs(local_folder, exist_ok=True)
      
      local_folder = os.path.join(local_folder, cid) # add the CID as a subfolder
      
      # if the folder exists cleanup the content 
      if os.path.exists(local_folder):
        if os.path.isdir(local_folder):
          if show_logs:
            files = os.listdir(local_folder)
            self.Pd(f"Cleaning up {local_folder} with {files}")
          #end if show_logs
          shutil.rmtree(local_folder)
        else:
          # If it's a file, just remove it
          if show_logs:
            self.Pd(f"Removing existing file {local_folder}")
          #end if show_logs
          os.remove(local_folder)
        #end if isdir
      #end if local_folder exists

      
      if show_logs:
        self.Pd(f"Downloading file {cid} to {local_folder}")
      # IPFS get the single ciphertext file
      ipfs_timeout = timeout if timeout else 90  # or IPFSCt.TIMEOUT
      start_time = time.time()
      self.__run_command(
        ["ipfs", "get", cid, "-o", local_folder],
        timeout=ipfs_timeout,
        raise_on_error=raise_on_error,
        show_logs=show_logs
      )
      download_elapsed_time = time.time() - start_time

      # Expect exactly one file
      if not os.path.isdir(local_folder):
        msg = f"Expected {local_folder} to be a directory after IPFS download, but it's not"
        if raise_on_error:
          raise RuntimeError(msg)
        else:
          self.P(msg, color='r')
          return

      contents = os.listdir(local_folder)
      if len(contents) != 1:
        msg = f"Expected 1 file in {local_folder}, found {contents}"
        if raise_on_error:
          raise RuntimeError(msg)
        else:
          self.P(msg, color='r')
          return

      cipher_path = os.path.join(local_folder, contents[0])
      
      out_path = None

      # Decrypt with AES-GCM
      start_time = time.time()
      try:
        with open(cipher_path, "rb") as fin:
          nonce = fin.read(12)
          meta_len_bytes = fin.read(4)
          meta_len = int.from_bytes(meta_len_bytes, "big")

          decryptor = Cipher(algorithms.AES(key), modes.GCM(nonce)).decryptor()
          
          # Read the metadata and decrypt it
          enc_meta_data = fin.read(meta_len)
          meta_data = decryptor.update(enc_meta_data) # TODO: verify if this is correct
          meta_dict = json.loads(meta_data.decode("utf-8"))

          original_filename = meta_dict.get("filename", "restored_file.bin")

          # File size + chunk logic to isolate last 16 bytes as GCM tag
          fin.seek(0, os.SEEK_END)
          total_size = fin.tell()
          data_start = 12 + 4 + meta_len
          tag_size = 16
          content_size = total_size - data_start - tag_size
          fin.seek(data_start, os.SEEK_SET)

          out_path = os.path.join(local_folder, original_filename)
          if return_absolute_path:
            out_path = os.path.abspath(out_path)
          chunk_size = 1024 * 1024

          with open(out_path, "wb") as fout:
            remaining = content_size
            while remaining > 0:
              read_len = min(chunk_size, remaining)
              chunk = fin.read(read_len)
              if not chunk:
                break
              fout.write(decryptor.update(chunk))
              remaining -= read_len
            #end while there are still bytes to read
            # Final 16 bytes => GCM tag
            tag = fin.read(16)
            # decryptor.authenticate_tag(tag)
            # final_pt = decryptor.finalize()
            final_pt = decryptor.finalize_with_tag(tag)
            if final_pt:
              fout.write(final_pt)
          #end with fout 
        #end with fin
        decrypt_elapsed_time = time.time() - start_time
      except Exception as e:
        out_path = None
        msg = f"Error decrypting file {cipher_path}: {e}"
        if raise_on_error:
          raise RuntimeError(msg)
        else:
          self.P(msg, color='r')
      #end try

      # Optionally remove the ciphertext
      os.remove(cipher_path)      

      if out_path:
        if show_logs:
          self.P(f"Downloaded/descrypted in {download_elapsed_time:.1f}s/{decrypt_elapsed_time:.1f}s <{cid}> to {out_path}")
        self.__downloaded_files[cid] = out_path      
      #end if out_path is not None
      return out_path

    @require_ipfs_started
    def get_pickle(
      self,
      cid: str,
      local_folder: str = None,
      secret: str = None,
      timeout: int = None,
      pin: bool = True,
      raise_on_error: bool = False,
      show_logs: bool = True,
    ):
      """
      Retrieve and deserialize a pickle file from R1FS by CID.
      Uses get_file under the hood to download and decrypt the file, then loads it with pickle.

      Parameters
      ----------
      cid : str
        The folder CID (wrapped single file).

      local_folder : str, optional
        Destination folder. If None, we default to something like self.__downloads_dir/<CID>.

      secret : str, optional
        Passphrase for AES-GCM. Must not be empty. Defaults to 'ratio1'.

      timeout : int, optional
        Maximum seconds for the IPFS get. If None, use IPFSCt.TIMEOUT.

      pin : bool, optional
        If True, we optionally pin the folder. Default True.

      raise_on_error : bool, optional
        If True, raise an Exception on command errors/timeouts. Otherwise logs them. Default False.

      show_logs : bool, optional
        If True, logs steps via self.P / self.Pd. Default True.

      Returns
      -------
      any
        The deserialized Python object from the pickle file.

      Raises
      ------
      ValueError
        If the secret is empty.

      RuntimeError
        If multiple or zero files are found in the downloaded folder,
        or if we fail to parse the JSON metadata.

      Exception
        If the GCM tag is invalid or the IPFS command times out
        and raise_on_error=True.

      Examples
      --------
      >>> # Simple usage with default passphrase
      >>> data = engine.get_pickle("QmEncFolderXYZ")
      >>> print(data)
      {'key': 'value', 'list': [1, 2, 3]}
      
      """
      import pickle
      
      # Use get_file to download and decrypt the file
      file_path = self.get_file(
        cid=cid,
        local_folder=local_folder,
        secret=secret,
        timeout=timeout,
        pin=pin,
        raise_on_error=raise_on_error,
        show_logs=show_logs,
        return_absolute_path=True
      )
      
      if file_path is None:
        if raise_on_error:
          raise RuntimeError(f"Failed to retrieve file for CID {cid}")
        else:
          if show_logs:
            self.P(f"Failed to retrieve file for CID {cid}", color='r')
          return None
      
      # Load the pickle file
      try:
        with open(file_path, "rb") as f:
          data = pickle.load(f)
        
        if show_logs:
          self.Pd(f"Successfully loaded pickle data from {file_path}")
        
        # Clean up the temporary file
        os.remove(file_path)
        if show_logs:
          self.Pd(f"Cleaned up temporary pickle file: {file_path}")
        
        return data
        
      except Exception as e:
        msg = f"Error loading pickle file {file_path}: {e}"
        if raise_on_error:
          raise RuntimeError(msg)
        else:
          if show_logs:
            self.P(msg, color='r')
          return None

    @require_ipfs_started
    def get_json(
      self,
      cid: str,
      local_folder: str = None,
      secret: str = None,
      timeout: int = None,
      pin: bool = True,
      raise_on_error: bool = False,
      show_logs: bool = True,
    ):
      """
      Retrieve and deserialize a JSON file from R1FS by CID.
      Uses get_file under the hood to download and decrypt the file, then loads it with json.

      Parameters
      ----------
      cid : str
        The folder CID (wrapped single file).

      local_folder : str, optional
        Destination folder. If None, we default to something like self.__downloads_dir/<CID>.

      secret : str, optional
        Passphrase for AES-GCM. Must not be empty. Defaults to 'ratio1'.

      timeout : int, optional
        Maximum seconds for the IPFS get. If None, use IPFSCt.TIMEOUT.

      pin : bool, optional
        If True, we optionally pin the folder. Default True.

      raise_on_error : bool, optional
        If True, raise an Exception on command errors/timeouts. Otherwise logs them. Default False.

      show_logs : bool, optional
        If True, logs steps via self.P / self.Pd. Default True.

      Returns
      -------
      any
        The deserialized JSON object from the file.

      Raises
      ------
      ValueError
        If the secret is empty.

      RuntimeError
        If multiple or zero files are found in the downloaded folder,
        or if we fail to parse the JSON metadata.

      Exception
        If the GCM tag is invalid or the IPFS command times out
        and raise_on_error=True.

      Examples
      --------
      >>> # Simple usage with default passphrase
      >>> data = engine.get_json("QmEncFolderXYZ")
      >>> print(data)
      {'key': 'value', 'list': [1, 2, 3]}
      
      """
      # Use get_file to download and decrypt the file
      file_path = self.get_file(
        cid=cid,
        local_folder=local_folder,
        secret=secret,
        timeout=timeout,
        pin=pin,
        raise_on_error=raise_on_error,
        show_logs=show_logs,
        return_absolute_path=True
      )
      
      if file_path is None:
        if raise_on_error:
          raise RuntimeError(f"Failed to retrieve file for CID {cid}")
        else:
          if show_logs:
            self.P(f"Failed to retrieve file for CID {cid}", color='r')
          return None
      
      # Load the JSON file
      try:
        with open(file_path, "r", encoding="utf-8") as f:
          data = json.load(f)
        
        if show_logs:
          self.Pd(f"Successfully loaded JSON data from {file_path}")
        
        # Clean up the temporary file
        os.remove(file_path)
        if show_logs:
          self.Pd(f"Cleaned up temporary JSON file: {file_path}")
        
        return data
        
      except Exception as e:
        msg = f"Error loading JSON file {file_path}: {e}"
        if raise_on_error:
          raise RuntimeError(msg)
        else:
          if show_logs:
            self.P(msg, color='r')
          return None


    @require_ipfs_started
    def list_pins(self):
      """
      List pinned CIDs via 'ipfs pin ls --type=recursive'.
      Returns a list of pinned CIDs.
      """
      output = self.__run_command(["ipfs", "pin", "ls", "--type=recursive"])
      pinned_cids = []
      for line in output.split("\n"):
        line = line.strip()
        if not line:
          continue
        parts = line.split()
        if len(parts) > 0:
          pinned_cids.append(parts[0])
      return pinned_cids
    
    
    @require_ipfs_started
    def is_cid_available(self, cid: str, max_wait=3) -> bool:
      """
      Check if a CID is available on IPFS.
      Returns True if the CID is available, False otherwise.
      
      Parameters
      ----------
      cid : str
          The CID to check.
          
      max_wait : int
          The maximum time to wait for the CID to be found.
          
      """
      CMD = ["ipfs", "block", "stat", cid]  
      result = True
      try:
        res = self.__run_command(CMD, timeout=max_wait)
        self.Pd(f"{cid} is available:\n{res}")
      except Exception as e:
        result = False
      return result


    ########## START DELETE METHODS ##########
    if True:
      def is_pinned(self, cid: str, show_logs: bool = False) -> bool:
        """
        Check if a CID is pinned locally.

        Parameters
        ----------
        cid : str
            The CID to check.

        show_logs : bool, optional
            Whether to show logs. Default is False.

        Returns
        -------
        bool
            True if the CID is pinned, False otherwise.

        Examples
        --------
        >>> if engine.is_pinned("QmHash123..."):
        >>>   print("File is pinned")
        """
        try:
          output = self.__run_command(
            ["ipfs", "pin", "ls", "--type=recursive", cid],
            raise_on_error=False,
            show_logs=False
          )
          is_pinned = cid in output
          if show_logs:
            self.Pd(f"CID {cid} pinned: {is_pinned}")
          return is_pinned
        except Exception as e:
          if show_logs:
            self.Pd(f"Error checking if CID {cid} is pinned: {e}")
          return False

      def unpin_file(
        self,
        cid: str,
        unpin_remote: bool = True,
        show_logs: bool = True,
        raise_on_error: bool = False
      ) -> bool:
        """
        Unpin a file from R1FS locally and optionally on the relay.
        This marks the file for garbage collection but does not immediately delete it.

        Parameters
        ----------
        cid : str
            The CID to unpin.

        unpin_remote : bool, optional
            Whether to also unpin from the relay. Default is True.

        show_logs : bool, optional
            Whether to show logs. Default is True.

        raise_on_error : bool, optional
            If True, raise an Exception on errors. Otherwise logs them. Default is False.

        Returns
        -------
        bool
            True if unpinning was successful, False otherwise.

        Examples
        --------
        >>> # Unpin locally and on relay
        >>> engine.unpin_file("QmHash123...")

        >>> # Unpin only locally
        >>> engine.unpin_file("QmHash123...", unpin_remote=False)
        """
        if cid in [None, ""]:
          msg = "CID parameter cannot be None or empty"
          if raise_on_error:
            raise ValueError(msg)
          else:
            if show_logs:
              self.P(msg, color='r')
            return False

        success = True

        try:
          # First, unpin locally
          result = self.__run_command(
            ["ipfs", "pin", "rm", cid],
            raise_on_error=raise_on_error,
            show_logs=False
          )

          if show_logs:
            self.Pd(f"Unpinned CID locally: {cid}")

        except Exception as e:
          msg = f"Error unpinning CID {cid} locally: {e}"
          success = False
          if raise_on_error:
            raise RuntimeError(msg)
          else:
            if show_logs:
              self.P(msg, color='r')
            return False

        # Then, notify the relay to unpin (mirrors add_file behavior)
        if unpin_remote and self.__ipfs_relay_api is not None:
          try:
            request_url = f"{self.__ipfs_relay_api}/api/v0/pin/rm?arg={cid}"
            response = requests.post(
              request_url,
              auth=HTTPBasicAuth(self.__ipfs_api_key_username, self.__ipfs_api_key_password),
              verify=self.__ipfs_certificate_path
            )

            if response.status_code == 200:
              if show_logs:
                self.Pd(f"Relay successfully notified to unpin CID={cid}")
            else:
              msg = f"Failed to notify relay to unpin CID {cid}: {response.text}"
              if raise_on_error:
                raise RuntimeError(msg)
              else:
                if show_logs:
                  self.P(msg, color='r')
                success = False
            #end if response status code
          except requests.RequestException as e:
            msg = f"Error notifying relay to unpin CID {cid}: {e}"
            if raise_on_error:
              raise RuntimeError(msg)
            else:
              if show_logs:
                self.P(msg, color='r')
              success = False
          #end try
        #end if relay API exists

        # Remove from tracking dicts if present
        if success:
          self.__uploaded_files.pop(cid, None)
          self.__downloaded_files.pop(cid, None)

        return success

      def delete_file(
        self,
        cid: str,
        unpin_remote: bool = True,
        run_gc: bool = False,
        cleanup_local_files: bool = False,
        show_logs: bool = True,
        raise_on_error: bool = False
      ) -> bool:
        """
        Delete a file from R1FS by unpinning it locally and optionally on the relay,
        then optionally running garbage collection.

        Note: Unpinning removes the file from your local node and (optionally) the relay.
        However, if other nodes have pinned this content, it may remain accessible on the network.

        Parameters
        ----------
        cid : str
            The CID to delete.

        unpin_remote : bool, optional
            Whether to also unpin from the relay. Default is True.
            Set to False if you only want to free local storage.

        run_gc : bool, optional
            Whether to run garbage collection immediately after unpinning.
            Default is False (GC runs automatically with --enable-gc flag on daemon).
            Set to True for immediate disk space reclamation.

        cleanup_local_files : bool, optional
            Whether to also remove local downloaded files from the downloads directory.
            Default is False.

        show_logs : bool, optional
            Whether to show logs. Default is True.

        raise_on_error : bool, optional
            If True, raise an Exception on errors. Otherwise logs them. Default is False.

        Returns
        -------
        bool
            True if deletion was successful, False otherwise.

        Examples
        --------
        >>> # Simple delete (unpin locally and on relay)
        >>> engine.delete_file("QmHash123...")

        >>> # Delete with immediate garbage collection
        >>> engine.delete_file("QmHash123...", run_gc=True)

        >>> # Delete only locally, keep on relay
        >>> engine.delete_file("QmHash123...", unpin_remote=False)

        >>> # Full cleanup: unpin everywhere, GC, and remove local files
        >>> engine.delete_file("QmHash123...", run_gc=True, cleanup_local_files=True)
        """
        if cid in [None, ""]:
          msg = "CID parameter cannot be None or empty"
          if raise_on_error:
            raise ValueError(msg)
          else:
            if show_logs:
              self.P(msg, color='r')
            return False

        try:
          # Unpin the file (locally and optionally on relay)
          success = self.unpin_file(
            cid=cid,
            unpin_remote=unpin_remote,
            show_logs=show_logs,
            raise_on_error=raise_on_error
          )

          if not success:
            return False

          # Optional: clean up local downloaded files
          if cleanup_local_files:
            local_folder = os.path.join(self.__downloads_dir, cid)
            if os.path.exists(local_folder):
              try:
                if os.path.isdir(local_folder):
                  shutil.rmtree(local_folder)
                else:
                  os.remove(local_folder)
                if show_logs:
                  self.Pd(f"Removed local files for CID {cid}")
              except Exception as e:
                msg = f"Error removing local files for CID {cid}: {e}"
                if show_logs:
                  self.P(msg, color='r')
                # Don't fail the whole operation if local cleanup fails

          # Optional: run garbage collection immediately
          if run_gc:
            if show_logs:
              self.Pd("Running garbage collection...")
            try:
              gc_result = self.__run_command(
                ["ipfs", "repo", "gc"],
                raise_on_error=False,
                show_logs=False  # GC output can be very verbose
              )
              if show_logs:
                self.P(f"Deleted file {cid} and ran garbage collection", color='g')
            except Exception as e:
              msg = f"Error running garbage collection: {e}"
              if show_logs:
                self.P(msg, color='r')
              # Don't fail if GC fails - the unpin was successful
          else:
            if show_logs:
              location = "local + relay" if unpin_remote else "local only"
              self.P(f"Unpinned file {cid} ({location})", color='g')

          return True

        except Exception as e:
          msg = f"Error deleting file {cid}: {e}"
          if raise_on_error:
            raise RuntimeError(msg)
          else:
            if show_logs:
              self.P(msg, color='r')
            return False

      def delete_files(
        self,
        cids: list,
        unpin_remote: bool = True,
        run_gc_after_all: bool = True,
        cleanup_local_files: bool = False,
        show_logs: bool = True,
        raise_on_error: bool = False,
        continue_on_error: bool = True
      ) -> dict:
        """
        Delete multiple files from R1FS in bulk.
        More efficient than calling delete_file repeatedly as it can run GC once at the end.

        Parameters
        ----------
        cids : list
            List of CIDs to delete.

        unpin_remote : bool, optional
            Whether to also unpin from the relay. Default is True.

        run_gc_after_all : bool, optional
            Whether to run garbage collection once after all deletions.
            Default is True. More efficient than running GC for each file.

        cleanup_local_files : bool, optional
            Whether to also remove local downloaded files. Default is False.

        show_logs : bool, optional
            Whether to show logs. Default is True.

        raise_on_error : bool, optional
            If True, raise an Exception on first error. Default is False.

        continue_on_error : bool, optional
            Whether to continue deleting remaining files if one fails.
            Only used if raise_on_error is False. Default is True.

        Returns
        -------
        dict
            Dictionary with keys:
            - 'success': list of successfully deleted CIDs
            - 'failed': list of CIDs that failed to delete
            - 'total': total number of CIDs processed
            - 'success_count': number of successful deletions
            - 'failed_count': number of failed deletions

        Examples
        --------
        >>> # Delete multiple files efficiently
        >>> cids = ["QmHash1...", "QmHash2...", "QmHash3..."]
        >>> result = engine.delete_files(cids)
        >>> print(f"Deleted {result['success_count']} of {result['total']} files")

        >>> # Delete locally only, no GC
        >>> result = engine.delete_files(cids, unpin_remote=False, run_gc_after_all=False)

        >>> # Full cleanup
        >>> result = engine.delete_files(
        >>>   cids,
        >>>   cleanup_local_files=True,
        >>>   run_gc_after_all=True
        >>> )
        """
        if not isinstance(cids, list):
          msg = "cids parameter must be a list"
          if raise_on_error:
            raise ValueError(msg)
          else:
            if show_logs:
              self.P(msg, color='r')
            return {
              'success': [],
              'failed': cids if isinstance(cids, list) else [cids],
              'total': 0,
              'success_count': 0,
              'failed_count': 0
            }

        if len(cids) == 0:
          if show_logs:
            self.Pd("No CIDs provided for deletion")
          return {
            'success': [],
            'failed': [],
            'total': 0,
            'success_count': 0,
            'failed_count': 0
          }

        if show_logs:
          self.P(f"Deleting {len(cids)} files from R1FS...", color='m')

        success_list = []
        failed_list = []

        for i, cid in enumerate(cids):
          if show_logs:
            self.Pd(f"Processing {i+1}/{len(cids)}: {cid}")

          try:
            # Delete without running GC for each file (we'll do it once at the end)
            result = self.delete_file(
              cid=cid,
              unpin_remote=unpin_remote,
              run_gc=False,  # Don't GC per file
              cleanup_local_files=cleanup_local_files,
              show_logs=False,  # Reduce log spam
              raise_on_error=raise_on_error
            )

            if result:
              success_list.append(cid)
            else:
              failed_list.append(cid)
              if not continue_on_error:
                break
          except Exception as e:
            failed_list.append(cid)
            if show_logs:
              self.P(f"Error deleting CID {cid}: {e}", color='r')
            if raise_on_error:
              raise
            if not continue_on_error:
              break

        # Run garbage collection once at the end (more efficient)
        if run_gc_after_all and len(success_list) > 0:
          if show_logs:
            self.P("Running garbage collection for all deleted files...", color='m')
          try:
            gc_result = self.__run_command(
              ["ipfs", "repo", "gc"],
              raise_on_error=False,
              show_logs=False
            )
            if show_logs:
              self.Pd("Garbage collection completed")
          except Exception as e:
            if show_logs:
              self.P(f"Warning: Garbage collection failed: {e}", color='r')
            # Don't fail the whole operation if GC fails

        result = {
          'success': success_list,
          'failed': failed_list,
          'total': len(cids),
          'success_count': len(success_list),
          'failed_count': len(failed_list)
        }

        if show_logs:
          location = "local + relay" if unpin_remote else "local only"
          self.P(
            f"Bulk delete completed: {result['success_count']}/{result['total']} succeeded ({location})",
            color='g' if result['failed_count'] == 0 else 'y'
          )
          if result['failed_count'] > 0:
            self.P(f"Failed to delete {result['failed_count']} files", color='r')

        return result

      def garbage_collect(self, show_logs: bool = True, raise_on_error: bool = False) -> bool:
        """
        Manually run IPFS garbage collection to reclaim disk space from unpinned files.

        Note: The IPFS daemon runs with --enable-gc flag, so GC happens automatically.
        This method is useful for immediate disk space reclamation.

        Parameters
        ----------
        show_logs : bool, optional
            Whether to show logs. Default is True.

        raise_on_error : bool, optional
            If True, raise an Exception on errors. Default is False.

        Returns
        -------
        bool
            True if garbage collection succeeded, False otherwise.

        Examples
        --------
        >>> # Run garbage collection manually
        >>> engine.garbage_collect()
        """
        try:
          if show_logs:
            self.P("Running IPFS garbage collection...", color='m')

          result = self.__run_command(
            ["ipfs", "repo", "gc"],
            raise_on_error=raise_on_error,
            show_logs=False  # GC output can be very verbose
          )

          if show_logs:
            # Count lines in output to show how many blocks were freed
            lines = result.strip().split('\n') if result else []
            blocks_removed = len([l for l in lines if l.strip()])
            self.P(f"Garbage collection completed (~{blocks_removed} blocks processed)", color='g')

          return True

        except Exception as e:
          msg = f"Error running garbage collection: {e}"
          if raise_on_error:
            raise RuntimeError(msg)
          else:
            if show_logs:
              self.P(msg, color='r')
            return False

      def cleanup_downloads(
        self,
        cid: str = None,
        show_logs: bool = True
      ) -> int:
        """
        Clean up downloaded files from the local downloads directory.
        This does NOT unpin files from IPFS, only removes local file copies.

        Parameters
        ----------
        cid : str, optional
            Specific CID to clean up. If None, cleans all downloads.

        show_logs : bool, optional
            Whether to show logs. Default is True.

        Returns
        -------
        int
            Number of items cleaned up.

        Examples
        --------
        >>> # Clean up all downloaded files
        >>> count = engine.cleanup_downloads()
        >>> print(f"Cleaned up {count} files")

        >>> # Clean up specific CID's downloads
        >>> engine.cleanup_downloads(cid="QmHash123...")
        """
        count = 0

        try:
          if not os.path.exists(self.__downloads_dir):
            if show_logs:
              self.Pd(f"Downloads directory does not exist: {self.__downloads_dir}")
            return 0

          if cid is not None:
            # Clean up specific CID
            target_path = os.path.join(self.__downloads_dir, cid)
            if os.path.exists(target_path):
              try:
                if os.path.isdir(target_path):
                  shutil.rmtree(target_path)
                else:
                  os.remove(target_path)
                count = 1
                if show_logs:
                  self.P(f"Cleaned up local files for CID: {cid}", color='g')
                # Remove from tracking dict
                self.__downloaded_files.pop(cid, None)
              except Exception as e:
                if show_logs:
                  self.P(f"Error cleaning up CID {cid}: {e}", color='r')
            else:
              if show_logs:
                self.Pd(f"No local files found for CID: {cid}")
          else:
            # Clean up all downloads
            for item in os.listdir(self.__downloads_dir):
              item_path = os.path.join(self.__downloads_dir, item)
              try:
                if os.path.isdir(item_path):
                  shutil.rmtree(item_path)
                else:
                  os.remove(item_path)
                count += 1
              except Exception as e:
                if show_logs:
                  self.P(f"Error removing {item_path}: {e}", color='r')

            if show_logs:
              self.P(f"Cleaned up {count} items from downloads directory", color='g')

            # Clear all tracking
            self.__downloaded_files.clear()

        except Exception as e:
          if show_logs:
            self.P(f"Error during cleanup: {e}", color='r')

        return count

    ######## END DELETE METHODS #########

    def calculate_file_cid(
      self,
      file_path: str,
      nonce: int,
      secret: str = None,
      show_logs: bool = True
    ) -> str:
      """
      Calculate the CID (Content Identifier) of a file without adding it to IPFS.
      This method encrypts the file the same way as add_file and computes the CID
      that would be generated if the encrypted file were added to IPFS.
      
      Parameters
      ----------
      file_path : str
          Path to the local file to calculate CID for.
          
      nonce : int
          Nonce for encryption. Required parameter for deterministic CID calculation.
          
      secret : str, optional
          Passphrase for AES-GCM encryption. Defaults to 'ratio1'.
          
      show_logs : bool, optional
          Whether to show logs via self.P / self.Pd. Default is True.
          
      Returns
      -------
      str
          The CID of the encrypted file (e.g., "QmHash...")
          
      Raises
      ------
      FileNotFoundError
          If file_path does not exist.
          
      ImportError
          If IPFS is not available or not running.
          
      ValueError
          If nonce is None or invalid.
          
      Examples
      --------
      >>> cid = engine.calculate_file_cid("/path/to/file.txt", nonce=12345)
      >>> print(cid)
      QmHash123ABC...
      """
      if not os.path.isfile(file_path):
        raise FileNotFoundError(f"File not found: {file_path}")

      if secret in ["", None]:
        secret = self.__DEFAULT_SECRET
        
      # Validate nonce parameter
      if nonce is None:
        raise ValueError("nonce parameter is required for deterministic CID calculation")
        
      # Check file size and throw an error if larger than 2 GB.
      file_size = os.path.getsize(file_path)
      if file_size > 2 * 1024 * 1024 * 1024:
        raise ValueError(f"File {file_path} is too large ({file_size} bytes). Maximum allowed size is 2 GB.")

      key = self._hash_secret(secret)
      nonce_bytes = random.Random(nonce).randbytes(12)

      original_basename = os.path.basename(file_path)

      # JSON metadata storing the original filename
      meta_dict = {"filename": original_basename}
      meta_bytes = json.dumps(meta_dict).encode("utf-8")

      if show_logs:
        self.Pd(f"Calculating CID for encrypted file: {file_path}")
        self.Pd(f"Nonce input: {nonce}, Generated nonce bytes: {nonce_bytes.hex()}")
        self.Pd(f"Original basename: {original_basename}")
        self.Pd(f"Metadata: {meta_dict}")
        self.Pd(f"Secret hash (first 16 bytes): {key[:16].hex()}")

      # Create temporary encrypted file (same as in add_file)
      tmp_cipher_path = os.path.join(tempfile.gettempdir(), uuid.uuid4().hex + ".bin")

      try:
        # Encrypt the file content (same as in add_file)
        encryptor = Cipher(algorithms.AES(key), modes.GCM(nonce_bytes)).encryptor()

        chunk_size = 1024 * 1024  # 1 MB chunks
        with open(file_path, "rb") as fin, open(tmp_cipher_path, "wb") as fout:
          # [nonce][4-byte-len][metadata][ciphertext][16-byte GCM tag]
          fout.write(nonce_bytes)
          meta_len = len(meta_bytes)
          fout.write(meta_len.to_bytes(4, "big"))
          # encrypt the metadata and save
          enc_meta_data = encryptor.update(meta_bytes)
          fout.write(enc_meta_data)

          while True:
            chunk = fin.read(chunk_size)
            if not chunk:
              break
            fout.write(encryptor.update(chunk))
          #end while there are still bytes to read
          final_ct = encryptor.finalize()
          fout.write(final_ct)
          # Append 16-byte GCM tag
          tag = encryptor.tag
          fout.write(tag)
        #end with fin, fout

        # Use IPFS to calculate the hash without adding the file
        output = self.__run_command(["ipfs", "add", "--only-hash", "-q", "-w", tmp_cipher_path], show_logs=show_logs)
        lines = output.strip().split("\n")
        self.P("output lines: ", json.dumps(lines))
        if not lines:
          raise RuntimeError("No output from 'ipfs add --only-hash -q -w' for ciphertext.")

        # Get the CID from IPFS
        file_cid = lines[0].strip()
        if show_logs:
          self.Pd(f"IPFS calculated CID: {file_cid}")
        
        return file_cid
        
      finally:
        # Clean up temporary encrypted file
        if os.path.exists(tmp_cipher_path):
          os.remove(tmp_cipher_path)

    def calculate_json_cid(
      self,
      data,
      nonce: int,
      fn: str = None,
      secret: str = None,
      show_logs: bool = True
    ) -> str:
      """
      Calculate the CID (Content Identifier) of JSON data without adding it to IPFS.
      This method saves the JSON data to a temporary file, then uses calculate_file_cid
      to compute the CID that would be generated if the encrypted file were added to IPFS.
      
      Parameters
      ----------
      data : any
          JSON-serializable data to calculate CID for.
          
      nonce : int
          Nonce for encryption. Required parameter for deterministic CID calculation.
          
      fn : str, optional
          Filename to use for the JSON data. If None, uses DEFAULT_FILENAME_JSON.
          This affects the CID calculation as the filename is included in the metadata.
          
      secret : str, optional
          Passphrase for AES-GCM encryption. Defaults to 'ratio1'.
          
      show_logs : bool, optional
          Whether to show logs via self.P / self.Pd. Default is True.
          
      Returns
      -------
      str
          The CID of the encrypted JSON file (e.g., "QmHash...")
          
      Raises
      ------
      ImportError
          If IPFS is not available or not running.
          
      ValueError
          If nonce is None or invalid, or if data cannot be JSON serialized.
          
      Examples
      --------
      >>> data = {"name": "test", "value": 123}
      >>> cid = engine.calculate_json_cid(data, nonce=12345)
      >>> print(cid)
      QmHash123ABC...
      
      >>> # Use custom filename
      >>> cid = engine.calculate_json_cid(data, nonce=12345, fn="config.json")
      >>> print(cid)
      QmHash456DEF...
      """
      # Serialize JSON data
      try:
        json_data = json.dumps(data, sort_keys=True, separators=(',', ':'))
      except (TypeError, ValueError) as e:
        raise ValueError(f"Data cannot be JSON serialized: {e}")
      
      self.P(f"JSON data: {json_data}")

      # Use custom filename or default
      if fn is None:
        fn = DEFAULT_FILENAME_JSON

      if show_logs:
        self.Pd(f"Calculating CID for JSON data with {len(json_data)} characters, filename: {fn}")
      
      # Create temporary file for JSON data with the desired filename in a unique directory
      unique_dir = os.path.join(tempfile.gettempdir(), uuid.uuid4().hex)
      os.makedirs(unique_dir, exist_ok=True)
      tmp_json_path = os.path.join(unique_dir, fn)
      
      file_cid = None
      try:
        # Write JSON data to temporary file
        with open(tmp_json_path, "w") as f:
          f.write(json_data)
        
        if show_logs:
          self.Pd(f"About to call calculate_file_cid with: file_path={tmp_json_path}, nonce={nonce}, secret={secret[:10] if secret else 'None'}...")
        
        # Use calculate_file_cid to get the CID
        file_cid = self.calculate_file_cid(
          file_path=tmp_json_path,
          nonce=nonce,
          secret=secret,
          show_logs=True  # Don't show logs from calculate_file_cid
        )
        
        if show_logs:
          self.Pd(f"calculate_file_cid returned CID: {file_cid}")
        
      finally:
        # Clean up temporary JSON file and directory
        if os.path.exists(tmp_json_path):
          os.remove(tmp_json_path)
        if os.path.exists(unique_dir):
          os.rmdir(unique_dir)
      
      return file_cid

    def calculate_pickle_cid(
      self,
      data,
      nonce: int,
      fn: str = None,
      secret: str = None,
      show_logs: bool = True
    ) -> str:
      """
      Calculate the CID (Content Identifier) of pickle data without adding it to IPFS.
      This method saves the pickle data to a temporary file, then uses calculate_file_cid
      to compute the CID that would be generated if the encrypted file were added to IPFS.
      
      Parameters
      ----------
      data : any
          Python object to pickle and calculate CID for.
          
      nonce : int
          Nonce for encryption. Required parameter for deterministic CID calculation.
          
      fn : str, optional
          Filename to use for the pickle data. If None, generates a unique filename.
          This affects the CID calculation as the filename is included in the metadata.
          
      secret : str, optional
          Passphrase for AES-GCM encryption. Defaults to 'ratio1'.
          
      show_logs : bool, optional
          Whether to show logs via self.P / self.Pd. Default is True.
          
      Returns
      -------
      str
          The CID of the encrypted pickle file (e.g., "QmHash...")
          
      Raises
      ------
      ImportError
          If IPFS is not available or not running.
          
      ValueError
          If nonce is None or invalid, or if data cannot be pickled.
          
      Examples
      --------
      >>> data = {"name": "test", "value": 123}
      >>> cid = engine.calculate_pickle_cid(data, nonce=12345)
      >>> print(cid)
      QmHash123ABC...
      
      >>> # Use custom filename
      >>> cid = engine.calculate_pickle_cid(data, nonce=12345, fn="model.pkl")
      >>> print(cid)
      QmHash456DEF...
      """
      # Serialize pickle data
      try:
        import pickle
        pickle_data = pickle.dumps(data)
      except (TypeError, ValueError, pickle.PicklingError) as e:
        raise ValueError(f"Data cannot be pickled: {e}")
      
      if show_logs:
        self.Pd(f"Pickled data of type: {type(data)}, size: {len(pickle_data)} bytes")

      # Use custom filename or generate unique one
      if fn is None:
        fn = self._get_unique_or_complete_upload_name(fn=fn, suffix=".pkl")

      if show_logs:
        self.Pd(f"Calculating CID for pickle data with {len(pickle_data)} bytes, filename: {fn}")
      
      # Create temporary file for pickle data with the desired filename in a unique directory
      unique_dir = os.path.join(tempfile.gettempdir(), uuid.uuid4().hex)
      os.makedirs(unique_dir, exist_ok=True)
      tmp_pickle_path = os.path.join(unique_dir, fn)
      
      file_cid = None
      try:
        # Write pickle data to temporary file
        with open(tmp_pickle_path, "wb") as f:
          f.write(pickle_data)
        
        if show_logs:
          self.Pd(f"About to call calculate_file_cid with: file_path={tmp_pickle_path}, nonce={nonce}, secret={secret[:10] if secret else 'None'}...")
        
        # Use calculate_file_cid to get the CID
        file_cid = self.calculate_file_cid(
          file_path=tmp_pickle_path,
          nonce=nonce,
          secret=secret,
          show_logs=True  # Don't show logs from calculate_file_cid
        )
        
        if show_logs:
          self.Pd(f"calculate_file_cid returned CID: {file_cid}")
        
      finally:
        # Clean up temporary pickle file and directory
        if os.path.exists(tmp_pickle_path):
          os.remove(tmp_pickle_path)
        if os.path.exists(unique_dir):
          os.rmdir(unique_dir)
      
      return file_cid
      

  # Start/stop IPFS methods (R1FS API)
  if True:
    @property
    def is_ipfs_warmed(self) -> bool:
      """
      @Deprecated:
      This method is deprecated and will be removed in future versions.
      Check if IPFS is warmed up (connected to the relay and has been for a while).
      """
      return True

    def is_ipfs_daemon_running(
      self,
      host="127.0.0.1",
      port=5001,
      method="POST",
      timeout=3
    ) -> bool:
      """
      Checks if an IPFS daemon is running by calling /api/v0/version
      on the specified host and port. Some configurations require
      POST instead of GET, so we allow a method argument.

      Returns:
          bool: True if the IPFS daemon responds successfully; False otherwise.
      """
      url = f"http://{host}:{port}/api/v0/version"
      result = False
      output = None
      try:
        if method.upper() == "POST":
          response = requests.post(url, timeout=timeout)
        else:
          response = requests.get(url, timeout=timeout)

        if response.status_code == 200:
          data = response.json()
          output = str(data)
          if "Version" in data:
            result = True
          else:
            result = False
        else:
          result = False
          output = f"Status code: {response.status_code}"

      except Exception as e:
        result = False
        output = str(e)
      self.P(f"IPFS daemon run-check: {result} ({output})")
      return result        
    
    def is_ipfs_daemon_ready(self, max_wait=30, step=1):
      """ Check with timeout if the IPFS daemon is running and ready to accept requests."""
      waited = 0
      while waited < max_wait:
        if self.is_ipfs_daemon_running():
          return True
        time.sleep(step)
        waited += step
      return False    
    
    def maybe_reset_ipfs(self):
      """ Reset the IPFS repository if needed, remove swarm key and ipfs home."""
      

    def maybe_start_ipfs(
      self, 
      base64_swarm_key: str = None, 
      ipfs_relay: str = None,
      ipfs_relay_api: str = None,
      ipfs_api_key_username: str = None,
      ipfs_api_key_password: str = None,
      ipfs_certificate_path: str = None,
    ) -> bool:
      """
      Initialize the local IPFS repo and attach it to the configured relay.
      TODO: (Vitalii) Split this into smaller methods.

      Parameters
      ----------
      base64_swarm_key : str, optional
          Base64-encoded swarm key. Falls back to
          ``EE_SWARM_KEY_CONTENT_BASE64`` when omitted.
      ipfs_relay : str, optional
          Relay multiaddr. Falls back to ``EE_IPFS_RELAY`` when omitted.
      ipfs_relay_api : str, optional
          Relay API endpoint. Falls back to ``EE_IPFS_RELAY_API`` when omitted.
      ipfs_api_key_username : str, optional
          Relay API username. Decoded from ``EE_IPFS_API_KEY_BASE64`` when
          omitted.
      ipfs_api_key_password : str, optional
          Relay API password. Decoded from ``EE_IPFS_API_KEY_BASE64`` when
          omitted.
      ipfs_certificate_path : str, optional
          TLS certificate file path. Materialized from
          ``EE_IPFS_CERTIFICATE_BASE64`` when omitted.

      Returns
      -------
      bool
          ``True`` when the daemon is running and connected to the relay,
          otherwise ``False``.
      """
      if self.ipfs_started:
        return
      self.__samehost_fix_attempted = False
      
      self.P("Starting R1FS...", color='m')
      
      if base64_swarm_key is None:
        base64_swarm_key = os.getenv(IPFSCt.EE_SWARM_KEY_CONTENT_BASE64_ENV_KEY)
        if base64_swarm_key is not None:
          self.P(f"Found env IPFS swarm key: {str(base64_swarm_key)[:4]}...", color='d')
          if len(base64_swarm_key) < 10:
            self.P(f"Invalid IPFS swarm key: `{base64_swarm_key}`", color='r')
            return False
        
      if ipfs_relay is None:
        ipfs_relay = os.getenv(IPFSCt.EE_IPFS_RELAY_ENV_KEY)
        if ipfs_relay is not None:
          self.P(f"Found env IPFS relay: {ipfs_relay}", color='d')
          if len(ipfs_relay) < 10:
            self.P(f"Invalid IPFS relay: `{ipfs_relay}`", color='r')
            return False

      if ipfs_relay_api is None:
        ipfs_relay_api = os.getenv(IPFSCt.EE_IPFS_RELAY_API_KEY)
        if ipfs_relay_api is not None:
          self.P(f"Found env IPFS relay API: {ipfs_relay_api}", color='d')

      # Set up IPFS API key username and password.
      if ipfs_api_key_username is None or ipfs_api_key_password is None:
        try:
          ipfs_api_key_base64 = os.getenv(IPFSCt.EE_IPFS_API_KEY_BASE64_KEY)
          ipfs_api_key_b = base64.b64decode(ipfs_api_key_base64)
          ipfs_api_key = str(ipfs_api_key_b, 'utf-8')
          split_api_key = ipfs_api_key.split(":")
          ipfs_api_key_username = split_api_key[0]
          ipfs_api_key_password = split_api_key[1]
        except Exception as e:
          self.P(f"An error occurred while extracting IPFS Relay username and password {e}", color='r')


      # Set up certificate
      if ipfs_certificate_path is None:
        try:
          certificate_encoded = os.getenv(IPFSCt.EE_IPFS_CERTIFICATE_BASE64_KEY)
          decoded = self.__decode_base64_gzip_to_text(certificate_encoded)

          with tempfile.NamedTemporaryFile(delete=False, mode='w', suffix='.crt') as f:
            f.write(decoded)
            ipfs_certificate_path = f.name

          if not os.path.isfile(ipfs_certificate_path):
            raise FileNotFoundError(f"Could not create certificate file : {ipfs_certificate_path}")
        except Exception as e:
          self.P(f"Error reading the certificate file: {e}", color='r')

      if not base64_swarm_key or not ipfs_relay:
        self.P("Missing env values EE_SWARM_KEY_CONTENT_BASE64 and EE_IPFS_RELAY.", color='r')
        return False
      
      self.__base64_swarm_key = base64_swarm_key
      self.__ipfs_relay = ipfs_relay
      self.__ipfs_relay_api = ipfs_relay_api
      self.__ipfs_api_key_username = ipfs_api_key_username
      self.__ipfs_api_key_password = ipfs_api_key_password
      self.__ipfs_certificate_path = ipfs_certificate_path
      hidden_base64_swarm_key = base64_swarm_key[:8] + "..." + base64_swarm_key[-8:]
      
      existing_ipfs_home = os.getenv("IPFS_PATH")
      home_ready = False
      if existing_ipfs_home:
        self.P(f"Found existing IPFS home: {existing_ipfs_home}", color='d')
        if IPFSCt.CACHE_ROOT in existing_ipfs_home and os.path.isdir(existing_ipfs_home):
          self.__ipfs_home = os.path.abspath(existing_ipfs_home)
          home_ready = True
        else:
          self.P(f"Invalid IPFS home: {existing_ipfs_home}", color='r')
        #endif
      #endif 
      if not home_ready:
        self.P("No existing IPFS home found, creating a new one.", color='d')
        ipfs_home = os.path.join(self.logger.base_folder, ".ipfs/")        
        os.makedirs(ipfs_home, exist_ok=True)
        self.__ipfs_home = os.path.abspath(ipfs_home)
        os.environ["IPFS_PATH"] = self.__ipfs_home
      
      config_path = os.path.join(self.__ipfs_home, "config")
      swarm_key_path = os.path.join(self.__ipfs_home, "swarm.key")

      msg = f"Starting R1FS <{self.__name}>:"
      msg += f"\n  IPFS Home: {self.__ipfs_home}"
      msg += f"\n  Relay:    {self.__ipfs_relay}"
      msg += f"\n  Download: {self.__downloads_dir}"
      msg += f"\n  Upload:   {self.__uploads_dir}"
      msg += f"\n  SwarmKey: {hidden_base64_swarm_key}"
      msg += f"\n  Debug:    {self.__debug}"
      msg += f"\n  Repo:     {self.ipfs_home}"
      self.P(msg, color='d')
      
      # Write the swarm key at every start.
      try:
        decoded_key = base64.b64decode(base64_swarm_key)
        with open(swarm_key_path, "wb") as f:
          f.write(decoded_key)
        os.chmod(swarm_key_path, 0o600)
        self.P("Swarm key written successfully.", color='g')
      except Exception as e:
        self.P(f"Error writing swarm.key: {e}", color='r')
        return False

      if not os.path.isfile(config_path):
        # Repository is not initialized; init.
        try:
          self.P("Initializing IPFS repository...")
          self.__run_command(["ipfs", "init"])
        except Exception as e:
          self.P(f"Error during IPFS init: {e}", color='r')
          return False
      else:
        self.P(f"IPFS repository already initialized in {config_path}.", color='g')

      if not self.__samehost_fix_enabled():
        self.__cleanup_samehost_relay_workaround()

      # Check if daemon is already running by attempting to get the node id.
      try:
        self.P("Trying to see if IPFS daemon is running...", color='d')
        n_ipfs_daemon_checks = 3
        for attempt in range(1, n_ipfs_daemon_checks + 1):
          ipfs_daemon_running = self.is_ipfs_daemon_running()
          if ipfs_daemon_running:
            break
          else:
            self.P(f"Check {attempt}/{n_ipfs_daemon_checks} IPFS started: {ipfs_daemon_running}", color='d')
            time.sleep(2)
        #end for
        
        if ipfs_daemon_running:
          self.P("IPFS daemon already running", color='g')
        else:
          # If not running, start the daemon in the background.
          self.P("IPFS daemon not running. Trying to start...", color='r')     
          ###################################################
          #######             CLEANUP PHASE            ######
          ###################################################                            
          # we start by removing any existing bootstrap nodes
          try:
            self.P("Removing public IPFS bootstrap nodes...")
            self.__run_command(["ipfs", "bootstrap", "rm", "--all"])
          except Exception as e:
            self.P(f"Error removing bootstrap nodes: {e}", color='r')
               
          # then delete the repository lock file if it exists
          lock_file = os.path.join(self.__ipfs_home, "repo.lock")
          if os.path.isfile(lock_file):
            self.P(f"Deleting lock file {lock_file}...")
            os.remove(lock_file)
          else:
            self.P(f"Lock file {lock_file} not found.")
          # next check if the api is zero-length - if so, delete it
          api_file = os.path.join(self.__ipfs_home, "api")
          if os.path.isfile(api_file) and os.path.getsize(api_file) == 0:
            self.P(f"Deleting zero-length api file {api_file}...")
            os.remove(api_file)
          #endif            
          # now we can start the daemon
          ###################################################
          #######        END OF CLEANUP PHASE        ########
          ###################################################
          self.__set_reprovider_interval()
          self.__set_reprovider_strategy()
          self.__disable_auto_tls()
          self.__set_routing_type()
          self.__disable_ws_transport()
          self.__disable_mdns_discovery()
          self.__bootstrap_add(self.__ipfs_relay)
          ipfs_daemon_running = self.__start_daemon()
          if not ipfs_daemon_running:
            self.P("Failed to start IPFS daemon after multiple attempts.", color='r')
            return False
        #end if daemon running
      except Exception as e:
        self.P(f"Error starting IPFS daemon: {e}", color='r')
        return

      # last phase: connect to the relay      
      try:
        relay_ip = ipfs_relay.split("/")[2]
        self.P("Getting the IPFS ID...")
        my_id = self.__get_id()
        assert my_id != ERROR_TAG, "Failed to get IPFS ID."
        self.P("Checking swarm peers...")
        swarm_peers = self._get_swarm_peers()
        if len(swarm_peers) > 0:
          self.P(f"{len(swarm_peers)} swarm peers detected. Checking for relay connection...")
          relay_found = self._check_and_record_relay_connection(debug=True)
          if relay_found:
            self.__ipfs_started = True
            self.P(f"{my_id} connected to: {relay_ip}", color='g', boxed=True)
          else:
            self.P("No relay connection found in swarm peers.", color='r')
            self.__ipfs_started = False
          #end if relay_found
        # endif at least 1 swarm peer
        
        if not self.__ipfs_started:
          msg =  f"Connecting to R1FS relay"
          msg += f"\n  IPFS Home:  {self.ipfs_home}"
          msg += f"\n  IPFS ID:    {my_id}"
          msg += f"\n  IPFS Addr:  {self.__ipfs_address}"
          msg += f"\n  IPFS Agent: {self.__ipfs_agent}"
          msg += f"\n  Relay:      {ipfs_relay}"
          self.P(msg, color='m')
          result = self.__run_command(["ipfs", "swarm", "connect", ipfs_relay])
          if "connect" in result.lower() and "success" in result.lower():
            self.P(f"{my_id} connected to: {relay_ip}", color='g', boxed=True)
            self.__ipfs_started = True
            self.P("Re-checking swarm peers...")
            swarm_peers = self._get_swarm_peers()
            self.P(f"Swarm peers:\n {json.dumps(swarm_peers, indent=2)}")
            self._check_and_record_relay_connection(debug=True)
          else:
            self.P("Relay connection result did not indicate success.", color='r')
        # endif ipfs not started

        if self.__ipfs_started and self.__maybe_apply_samehost_relay_workaround():
          self.P("Reconnecting to relay after same-host relay workaround restart...", color='m')
          my_id = self.__get_id()
          relay_found = self._check_and_record_relay_connection(debug=True)
          if not relay_found:
            success, output, errors = self.__swarm_connect(ipfs_relay)
            if not success:
              self.P(f"Error reconnecting to relay after restart: {errors or output}", color='r')
              self.__ipfs_started = False
            else:
              self.__ipfs_started = True
              self._check_and_record_relay_connection(debug=True)
          else:
            self.__ipfs_started = True
          if self.__ipfs_started:
            self.P(f"{my_id} connected to: {relay_ip}", color='g', boxed=True)
      except Exception as e:
        self.P(f"Error connecting to relay: {e}", color='r')
      #end try
      return self.ipfs_started

  def __decode_base64_gzip_to_text(self, encoded_str):
    try:
      # Step 1: Decode base64
      compressed_data = base64.b64decode(encoded_str)
      # Step 2: Decompress gzip
      with gzip.GzipFile(fileobj=BytesIO(compressed_data)) as f:
        decompressed_data = f.read()
      # Step 3: Convert bytes to string
      return decompressed_data.decode('utf-8')
    except Exception as e:
      self.P(f"Error decoding base64 string: {e}")
    return ''

if __name__ == '__main__':
  from ratio1 import Logger
  log = Logger("IPFST")
  # eng = R1FSEngine()
