#!/usr/bin/env bash
#clear
#export PS4='\e[90m+${LINENO} in ${#BASH_SOURCE[@]}>${FUNCNAME[0]}:${BASH_SOURCE[@]##*/} \e[0m'
#set -x

#echo "starting: $0 <LOG_LEVEL=$1>"

# ============================================================================
# odocker - Docker wrapper for oosh
# No flags, positional params only. Following: tmux→otmux, ssh→ossh, docker→odocker
# ============================================================================

# Docker workspaces base directory. Resolution order:
#   1. already-set env (odocker.env is sourced from user.env on shell startup)
#   2. legacy ODOCKER_WORKSPACES= in user.env (pre-migration installs)
#   3. $ODOCKER_WORKSPACES_DEFAULT below
# Update via: odocker workspace.set <?path>   (no path → falls back to the default)
ODOCKER_WORKSPACES_DEFAULT="/var/dev/EAMD.ucp/Components/com/ceruleanCircle/EAM/1_infrastructure/DockerWorkspaces"
: ${ODOCKER_WORKSPACES:=$(config get ODOCKER_WORKSPACES 2>/dev/null)}
: ${ODOCKER_WORKSPACES:=$ODOCKER_WORKSPACES_DEFAULT}

### new.method

# ─────────────────────────────────────────────────────────────────────────────
# COMPLETION HELPERS
# ─────────────────────────────────────────────────────────────────────────────

private.odocker.container.list.running() {
  docker ps --format '{{.Names}}' 2>/dev/null
}

private.odocker.container.list.all() {
  docker ps -a --format '{{.Names}}' 2>/dev/null
}

private.odocker.container.list.findable() {
  # Containers whose backing image has a dockerfile.path label, i.e. the
  # containers for which odocker.file.find would resolve via Tier 1.
  # Runs one `docker ps --filter ancestor=<img>` per findable image —
  # O(|findable images|) docker calls, each very fast, typically <500ms
  # total. Unfindable containers stay typeable; they just aren't
  # suggested.
  local img
  for img in $(private.odocker.image.list.findable); do
    docker ps -a --filter "ancestor=$img" --format '{{.Names}}' 2>/dev/null
  done
}

private.odocker.container.ports() {
  docker inspect --format '{{range $p, $conf := .HostConfig.PortBindings}}{{range $conf}}{{.HostPort}}->{{$p}} {{end}}{{end}}' "$1" 2>/dev/null
}

private.odocker.container.name() {
  docker inspect --format '{{.Name}}' "$1" 2>/dev/null | sed 's|^/||'
}

private.odocker.ensure.socket.access() {
  local socket="/var/run/docker.sock"
  [ -e "$socket" ] || return 0
  # Fix socket group if not owned by docker group
  local socketGroup
  socketGroup=$(stat -c "%G" "$socket" 2>/dev/null || stat -f "%Sg" "$socket" 2>/dev/null)
  if [ "$socketGroup" != "docker" ]; then
    echo "Docker socket has wrong group ($socketGroup instead of docker) — fixing with sudo..."
    sudo chgrp docker "$socket"
  fi
}

private.odocker.docker.socket.opt() {
  private.odocker.ensure.socket.access
  echo "-v /var/run/docker.sock:/var/run/docker.sock"
}

private.odocker.port.offset() {
  local value="${1:-0}"
  # Non-numeric values default to 0
  case "$value" in
    -[0-9]*) ;;
    *[!0-9]*) echo "0"; return ;;
  esac
  if [ "$value" -ge 1024 ]; then
    # Looks like an SSH port — derive offset (e.g. 9022 → 1000).
    local offset=$((value - 8022))
    # Values in [1024, 8022) produce a negative offset that would push
    # every mapped port below 1 (e.g. 3000 → offset -5022 → port 5001
    # becomes -21). That's almost certainly not the caller's intent —
    # they meant the value as a direct offset. Fall back to offset-mode
    # in that case, so `odocker run.sshd <img> <name> 3000` does the
    # obvious thing (SSH on 22+3000 = 11022) instead of erroring.
    if [ $((5001 + offset)) -lt 1 ]; then
      echo "$value"
    else
      echo "$offset"
    fi
  else
    # Treat as offset directly
    echo "$value"
  fi
}

private.odocker.image.list() {
  docker images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -v '<none>'
}

private.odocker.image.list.findable() {
  # Images file.find can resolve to a Dockerfile via Tier 1 (label).
  # These are exactly the images odocker.build produced (it adds the
  # dockerfile.path label — see odocker.build at odocker:408). Images
  # pulled from a registry or built with plain `docker build` have no
  # such label and are intentionally NOT suggested for `file.find` —
  # they're still typeable, so users can look them up to see the
  # Tier-4 "NOT FOUND + next steps" explanation.
  docker images --filter "label=dockerfile.path" --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -v '<none>'
}

private.odocker.workspace.list() {
  # List workspace/variant paths that contain a Dockerfile (glob, not find)
  local base="$ODOCKER_WORKSPACES"
  local dir
  for dir in "$base"/*/; do
    [ -f "$dir/Dockerfile" ] && echo "${dir#$base/}" | sed 's|/$||'
  done
  for dir in "$base"/*/*/; do
    [ -f "$dir/Dockerfile" ] && echo "${dir#$base/}" | sed 's|/$||'
  done
  return 0
}

private.odocker.image.resolve.from.workspace() {
  # Convert workspace path to image tag: nakedUbuntu/20.04.sshd → naked_ubuntu_20_04_sshd
  # Insert underscore before uppercase letters (camelCase → snake_case), then lowercase all
  echo "$1" | sed 's/\([a-z]\)\([A-Z]\)/\1_\2/g' | tr '[:upper:]/' '[:lower:]_' | tr '.' '_'
}

# ─────────────────────────────────────────────────────────────────────────────
# WORKSPACE MANAGEMENT
# ─────────────────────────────────────────────────────────────────────────────

odocker.workspace.get() # # show current Docker workspaces directory and where it's persisted
{
  # Use plain echo: the value IS this method's output, so it must be visible
  # at any LOG_LEVEL (console.log only prints at LOG_LEVEL>=3, which made
  # `odocker workspace.get` silent on the common LOG_LEVEL=1 setup).
  echo "${ODOCKER_WORKSPACES:-not set}"
  if [ -f "$CONFIG_PATH/odocker.env" ]; then
    info.log "(persisted in: \$CONFIG_PATH/odocker.env)"
  fi
}

odocker.workspace.set() # <?path:$ODOCKER_WORKSPACES_DEFAULT> # set Docker workspaces directory (persists to $CONFIG_PATH/odocker.env)
{
  local path="$1"
  # No arg → fall back to the platform default (declared at odocker:13).
  # Users who just want "reset to default" can now call `odocker workspace.set`
  # with no args instead of having to retype the long path.
  if [ -z "$path" ]; then
    path="$ODOCKER_WORKSPACES_DEFAULT"
    info.log "No path given — using default: $path"
  fi
  if [ ! -d "$path" ]; then
    error.log "Directory not found: $path"
    return 1
  fi

  # Canonicalize: a relative path (e.g. "./workspaces") would otherwise be
  # stored verbatim and mis-resolve from the cwd of future shell sessions.
  # Uses the oosh primitive — see /home/hannesn/oosh/this:398.
  this.absolutePath "$path"
  path="$RESULT"

  export ODOCKER_WORKSPACES="$path"

  # Persist via the idiomatic per-script env file pattern (mirrors log.env
  # and oosh.env — see /home/hannesn/oosh/log:276-301). `config` is invoked
  # as a script so each call runs in its own subshell; no need to source
  # the full config library into odocker just for three calls. The export
  # above is what lets the subshell see ODOCKER_WORKSPACES.
  #   - `config save odocker ODOCKER` → writes $CONFIG_PATH/odocker.env
  #     with every ODOCKER_* variable.
  #   - `config add odocker`         → appends `source …/odocker.env` to
  #     user.env so every shell picks it up at startup (idempotent; grep
  #     guard avoids redundant entries even though config.clean dedups).
  #   - `config unset ODOCKER_WORKSPACES` → removes any pre-existing key
  #     from user.env (migration from the old `config set` behaviour so
  #     odocker.env is the single source of truth — no dead keys).
  config save odocker ODOCKER
  grep -q "odocker\.env" "$CONFIG_PATH/user.env" 2>/dev/null || config add odocker
  config unset ODOCKER_WORKSPACES

  success.log "Workspace directory set to: $path"
  info.log   "  persisted in: \$CONFIG_PATH/odocker.env"
}
odocker.workspace.set.completion.path() { compgen -d "$1"; }

odocker.workspace.list() # # list all Dockerfile workspaces and their build status
{
  local base="$ODOCKER_WORKSPACES"
  if [ ! -d "$base" ]; then
    error.log "DockerWorkspaces not found: $base"
    return 1
  fi

  printf "%-35s %-25s %s\n" "WORKSPACE" "IMAGE TAG" "BUILT"
  printf "%-35s %-25s %s\n" "---------" "---------" "-----"

  local dir wsPath wsTag built
  for dir in "$base"/*/Dockerfile "$base"/*/*/Dockerfile; do
    [ -f "$dir" ] || continue
    wsPath=$(dirname "$dir")
    wsPath="${wsPath#$base/}"
    wsTag=$(private.odocker.image.resolve.from.workspace "$wsPath")
    if docker image inspect "$wsTag" >/dev/null 2>&1; then
      built="yes"
    else
      built="no"
    fi
    printf "%-35s %-25s %s\n" "$wsPath" "$wsTag" "$built"
  done
}

# ─────────────────────────────────────────────────────────────────────────────
# CONTAINER INSPECTION
# ─────────────────────────────────────────────────────────────────────────────

odocker.ps() # # list running containers
{
  {
    printf "CONTAINER ID\tNAMES\tIMAGE\tSTATUS\tPORTS\n"
    local id name image status ports
    while IFS=$'\t' read -r id name image status; do
      ports=$(private.odocker.container.ports "$name")
      printf "%s\t%s\t%s\t%s\t%s\n" "$id" "$name" "$image" "$status" "$ports"
    done < <(docker ps --format '{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}' 2>/dev/null)
  } | column -t -s $'\t'
}

odocker.list.running() # # list running containers
{
  odocker.ps "$@"
}

odocker.list() # # list images
{
  docker images --format 'table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}'
}

odocker.container.list() # # list all containers
{
  {
    printf "CONTAINER ID\tNAMES\tIMAGE\tSTATUS\tPORTS\n"
    local id name image status ports
    while IFS=$'\t' read -r id name image status; do
      ports=$(private.odocker.container.ports "$name")
      printf "%s\t%s\t%s\t%s\t%s\n" "$id" "$name" "$image" "$status" "$ports"
    done < <(docker ps -a --format '{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}' 2>/dev/null)
  } | column -t -s $'\t'
}

odocker.image.list() # # list all images
{
  docker images --format 'table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}'
}

# ─────────────────────────────────────────────────────────────────────────────
# FILE DISCOVERY
# ─────────────────────────────────────────────────────────────────────────────

private.odocker.resolve.image() {
  # Given a container name or image name, return the image name
  local input="$1"
  local image

  # Try as container first — get image from running/stopped container
  image=$(docker inspect --format '{{.Config.Image}}' "$input" 2>/dev/null)
  if [ -n "$image" ]; then
    echo "$image"
    return 0
  fi

  # Try as image directly
  if docker image inspect "$input" >/dev/null 2>&1; then
    echo "$input"
    return 0
  fi

  return 1
}

odocker.file.find() # <?containerOrImage> # find Dockerfile that built a container or image; with no arg, lists all containers + images as a discovery aid
{
  local input="$1"
  if [ -z "$input" ]; then
    echo "Usage: odocker file.find <containerOrImage>"
    echo ""
    odocker.container.list
    echo ""
    odocker.image.list
    return 0
  fi

  # Resolve to image name
  local image
  image=$(private.odocker.resolve.image "$input")
  if [ $? -ne 0 ]; then
    error.log "Not found as container or image: $input"
    return 1
  fi

  # --- Tier 1: Label check ---
  local labelPath
  labelPath=$(docker inspect --format '{{index .Config.Labels "dockerfile.path"}}' "$image" 2>/dev/null)
  if [ -n "$labelPath" ] && [ "$labelPath" != "<no value>" ]; then
    if [ -f "$labelPath" ]; then
      echo "Image: $image"
      echo "Dockerfile: $labelPath"
      echo "Directory: $(dirname "$labelPath")"
      RESULT="$labelPath"
      return 0
    fi
  fi

  local labelDir
  labelDir=$(docker inspect --format '{{index .Config.Labels "dockerfile.dir"}}' "$image" 2>/dev/null)
  if [ -n "$labelDir" ] && [ "$labelDir" != "<no value>" ]; then
    if [ -f "$labelDir/Dockerfile" ]; then
      echo "Image: $image"
      echo "Dockerfile: $labelDir/Dockerfile"
      echo "Directory: $labelDir"
      RESULT="$labelDir/Dockerfile"
      return 0
    fi
  fi

  # --- Tier 2: Compose label ---
  local composeDir
  composeDir=$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project.working_dir"}}' "$image" 2>/dev/null)
  if [ -n "$composeDir" ] && [ "$composeDir" != "<no value>" ]; then
    if [ -f "$composeDir/Dockerfile" ]; then
      echo "Image: $image"
      echo "Dockerfile: $composeDir/Dockerfile"
      echo "Directory: $composeDir"
      echo "Source: docker-compose label"
      RESULT="$composeDir/Dockerfile"
      return 0
    fi
  fi

  # --- Tier 3: Filesystem search in DockerWorkspaces ---
  local base="$ODOCKER_WORKSPACES"
  if [ -d "$base" ]; then
    local dir
    for dir in "$base"/*/Dockerfile "$base"/*/*/Dockerfile; do
      [ -f "$dir" ] || continue
      local wsDir=$(dirname "$dir")
      local wsTag
      wsTag=$(private.odocker.image.resolve.from.workspace "${wsDir#$base/}")
      if [ "$wsTag" = "$image" ] || [ "$wsTag" = "${image%%:*}" ]; then
        echo "Image: $image"
        echo "Dockerfile: $dir"
        echo "Directory: $wsDir"
        echo "Source: workspace name match"
        RESULT="$dir"
        return 0
      fi
    done
  fi

  # --- Tier 4: genuine miss — be honest and helpful ---
  # All three location strategies above have failed. Rather than dumping
  # `docker image history` as if it were a "reconstruct manually" Dockerfile
  # (no FROM, base-image metadata interleaved, not valid syntax — the
  # label misleads more than it informs), explain what we tried, what the
  # likely origin is, and what the user can do next.
  echo "Image: $image"
  echo "Dockerfile: NOT FOUND"
  echo
  echo "Tried:"
  echo "  [1] dockerfile.path / dockerfile.dir labels — absent"
  echo "  [2] docker-compose project working_dir label  — absent"
  echo "  [3] workspace glob under \$ODOCKER_WORKSPACES — no match"
  echo
  echo "Likely origins:"
  echo "  - pulled from a registry (no Dockerfile exists locally)"
  echo "  - built with plain 'docker build' (no discovery labels were added)"
  echo
  echo "Next steps:"
  echo "  - if you have the source dir, rebuild with 'odocker build <workspace>'"
  echo "    which adds dockerfile.path/dir labels so file.find hits Tier 1 next time"
  echo "  - if this came from a registry, 'docker inspect $image' may have hints"
  echo
  echo "Raw build history (NOT a valid Dockerfile — no FROM, base-image"
  echo "metadata is mixed in; shown for expert inspection only):"
  docker image history --no-trunc --format '  {{.CreatedBy}}' "$image" 2>/dev/null \
    | sed 's|/bin/sh -c #(nop)  ||g; s|/bin/sh -c ||g' \
    | tac
  RESULT=""
  return 1
}
odocker.file.find.completion.containerOrImage() {
  # Suggest only things file.find can resolve to a Dockerfile. Unfindable
  # items stay typeable (a user who wants to see why ubuntu:24.04 isn't
  # findable can still type it and get the Tier-4 "NOT FOUND + next
  # steps" explanation) — we just stop putting them in the tab-complete
  # surface, where their presence was actively misleading.
  private.odocker.image.list.findable
  private.odocker.container.list.findable
}

# ─────────────────────────────────────────────────────────────────────────────
# BUILD & RUN
# ─────────────────────────────────────────────────────────────────────────────

odocker.build.all() # # build all workspaces that have a Dockerfile
{
  local base="$ODOCKER_WORKSPACES"
  if [ ! -d "$base" ]; then
    error.log "DockerWorkspaces not found: $base"
    return 1
  fi

  local dir wsPath total=0 ok=0 fail=0
  for dir in "$base"/*/Dockerfile "$base"/*/*/Dockerfile; do
    [ -f "$dir" ] || continue
    wsPath=$(dirname "$dir")
    wsPath="${wsPath#$base/}"
    total=$((total + 1))
    console.log "--- Building $wsPath ($total) ---"
    if odocker.build "$wsPath"; then
      ok=$((ok + 1))
    else
      fail=$((fail + 1))
    fi
    echo ""
  done

  console.log "Build complete: $ok succeeded, $fail failed out of $total workspaces"
  [ $fail -eq 0 ]
}

odocker.build.completion.workspace() {
  private.odocker.workspace.list
}

odocker.build() # <workspace> # build image from DockerWorkspaces directory
{
  private.odocker.ensure.socket.access
  local workspace="$1"
  if [ -z "$workspace" ]; then
    echo "Usage: odocker build <workspace>"
    echo ""
    odocker.workspace.list
    return 1
  fi

  local workspaceDir="$ODOCKER_WORKSPACES/$workspace"
  if [ ! -f "$workspaceDir/Dockerfile" ]; then
    error.log "No Dockerfile found in $workspaceDir"
    return 1
  fi

  local imageTag
  imageTag=$(private.odocker.image.resolve.from.workspace "$workspace")

  local dockerfilePath
  dockerfilePath=$(cd "$workspaceDir" && pwd -P)/Dockerfile

  console.log "Building image: $imageTag from $workspaceDir"
  docker build \
    --label "dockerfile.path=$dockerfilePath" \
    --label "dockerfile.dir=$(dirname "$dockerfilePath")" \
    -t "$imageTag" "$workspaceDir"
  local rc=$?

  if [ $rc -eq 0 ]; then
    success.log "Image $imageTag built successfully"
  else
    error.log "Build failed with exit code $rc"
  fi
  return $rc
}

odocker.run() # <image> <?name> <?docker> # run container from image
{
  local image="$1"
  if [ -z "$image" ]; then
    echo "Usage: odocker run <image> <?name> <?docker>"
    return 1
  fi
  shift
  local name="$1"
  shift 2>/dev/null
  local dockerOpt=$(private.odocker.docker.socket.opt)

  local nameOpt=""
  if [ -n "$name" ]; then
    nameOpt="--name $name"
  fi

  console.log "Running image: $image"
  docker run -it $nameOpt $dockerOpt "$image" bash
}

odocker.run.sshd() # <image> <?name> <?portOrOffset:0> <?docker> # run container with port mappings (offset or SSH port, e.g. 1000 or 9022)
{
  local image="$1"
  if [ -z "$image" ]; then
    echo "Usage: odocker run.sshd <image> <?name> <?portOrOffset:0> <?docker>"
    return 1
  fi
  shift
  local name="$1"
  shift 2>/dev/null
  local portOffset=$(private.odocker.port.offset "$1")
  local sshPort=$((8022 + portOffset))
  shift 2>/dev/null
  local dockerArg="$1"
  local dockerOpt=$(private.odocker.docker.socket.opt)

  # Lowest default port is 5001; offset must not push any port below 1
  if [ $((5001 + portOffset)) -lt 1 ]; then
    error.log "Port offset $portOffset too low: would produce invalid port $((5001 + portOffset))"
    return 1
  fi

  local nameOpt=""
  if [ -n "$name" ]; then
    nameOpt="--name $name"
  fi

  console.log "Running image: $image with port mappings (offset: $portOffset)"

  # Inner helper: build + execute the port-mapped `docker run -d` call.
  # Passing "$1" when non-empty overrides the image's CMD — used for the
  # keep-alive fallback path below. Declared inside this method so it
  # doesn't pollute the top-level function table.
  private.odocker.run.sshd.launch() {
    local overrideCmd="$1"
    docker run -d \
      $nameOpt \
      $dockerOpt \
      -p $sshPort:22 \
      -p $((8080 + portOffset)):8080 \
      -p $((8443 + portOffset)):8443 \
      -p $((5001 + portOffset)):5001 \
      -p $((5002 + portOffset)):5002 \
      -p $((5005 + portOffset)):5005 \
      "$image" $overrideCmd
  }

  # First attempt: let the image's own ENTRYPOINT/CMD run. Happy path for
  # naked_*_sshd workspace images and anything built to start sshd.
  local cid
  cid=$(private.odocker.run.sshd.launch)
  local rc=$?
  if [ $rc -ne 0 ]; then
    error.log "docker run failed with exit code $rc"
    return $rc
  fi

  # Verify the container is actually Up after a brief settle. `docker run
  # -d` returns 0 once the container has been started, but a short-lived
  # default CMD (e.g. ubuntu:24.04 → bash, which exits immediately without
  # stdin) will already have died by the time we check. Detect and retry
  # with an explicit keep-alive so the "SSH in a moment" promise is kept.
  sleep 2
  if ! docker ps -q --no-trunc --filter "id=$cid" | grep -q .; then
    warn.log "Container exited immediately — image has no long-running default CMD. Retrying with keep-alive…"
    docker rm -f "$cid" >/dev/null 2>&1

    # Prefer `sshd -D -e` (daemon, foreground, log to stderr) so the SSH
    # port mapping is actually useful. Fall back to `sleep infinity` only
    # if sshd isn't installed in the image — and warn that SSH won't work
    # in that case (user can still `docker exec` into the container).
    local keepAlive
    if docker run --rm --entrypoint /bin/sh "$image" -c 'command -v sshd' >/dev/null 2>&1; then
      keepAlive="/usr/sbin/sshd -D -e"
    else
      keepAlive="sleep infinity"
      warn.log "$image has no sshd installed — container will stay Up but 'ssh -p $sshPort' won't connect (use 'docker exec' or install openssh-server in the image)"
    fi
    cid=$(private.odocker.run.sshd.launch "$keepAlive")
    rc=$?
    if [ $rc -ne 0 ]; then
      error.log "keep-alive retry failed (rc=$rc). Last logs:"
      docker logs --tail 20 "$cid" 2>&1 | sed 's/^/  /'
      return $rc
    fi
    sleep 1
    if ! docker ps -q --no-trunc --filter "id=$cid" | grep -q .; then
      error.log "container exited even with keep-alive CMD — inspect with 'docker logs $cid'"
      return 1
    fi
  fi

  local cname
  cname=$(private.odocker.container.name "$cid")
  success.log "Container $cname (${cid:0:12}) started (SSH: ssh -p $sshPort root@localhost)"
  if [ "$dockerArg" = "docker" ]; then
    odocker.install "$cname"
  fi
  return $rc
}

odocker.rebuild.completion.workspace() {
  private.odocker.workspace.list
}

odocker.rebuild() # <workspace> # remove old image and rebuild from Dockerfile
{
  private.odocker.ensure.socket.access
  local workspace="$1"
  if [ -z "$workspace" ]; then
    echo "Usage: odocker rebuild <workspace>"
    echo ""
    odocker.workspace.list
    return 1
  fi

  local imageTag
  imageTag=$(private.odocker.image.resolve.from.workspace "$workspace")

  if docker image inspect "$imageTag" >/dev/null 2>&1; then
    console.log "Removing old image: $imageTag"
    docker rmi -f "$imageTag" 2>/dev/null
  fi

  odocker.build "$workspace"
}

odocker.reset() # <image> <?portOrOffset:0> <?docker> # stop container, remove it, clear host key, start fresh (offset or SSH port)
{
  private.odocker.ensure.socket.access
  local image="$1"
  if [ -z "$image" ]; then
    echo "Usage: odocker reset <image> <?portOrOffset:0> <?docker>"
    return 1
  fi
  shift
  local rawPortArg="${1:-0}"
  shift 2>/dev/null
  local dockerArg="$1"
  local portOffset=$(private.odocker.port.offset "$rawPortArg")
  local sshPort=$((8022 + portOffset))

  # Step 1: Stop and remove any container on the SSH port
  local containerId
  containerId=$(docker ps -q --filter "publish=$sshPort" 2>/dev/null)
  if [ -n "$containerId" ]; then
    console.log "Stopping container on port $sshPort..."
    docker stop "$containerId" 2>/dev/null
    docker rm "$containerId" 2>/dev/null
  fi

  # Also clean up stopped containers on that port
  containerId=$(docker ps -aq --filter "publish=$sshPort" 2>/dev/null)
  if [ -n "$containerId" ]; then
    docker rm "$containerId" 2>/dev/null
  fi

  # Also clean up by container name (stopped containers lose port bindings)
  local containerName
  containerName="$(echo "$image" | tr '/:' '_')_sshd"
  docker rm -f "$containerName" 2>/dev/null

  # Step 2: Clear stale SSH host key
  ossh known.hosts.remove localhost "$sshPort"

  # Step 3: Start fresh container
  console.log "Starting fresh container from $image on port $sshPort..."
  odocker.run.sshd "$image" "" "$rawPortArg" "$dockerArg"
}

# ─────────────────────────────────────────────────────────────────────────────
# CONTAINER OPERATIONS
# ─────────────────────────────────────────────────────────────────────────────

odocker.exec() # <container> <?shell> # exec into running container
{
  local container="$1"
  if [ -z "$container" ]; then
    echo "Usage: odocker exec <container> <?shell>"
    return 1
  fi
  shift
  local shell="$1"
  if [ -z "$shell" ]; then
    if docker exec "$container" sh -c 'command -v bash' >/dev/null 2>&1; then
      shell="bash"
    else
      shell="sh"
    fi
  fi

  docker exec -it "$container" "$shell"
}

odocker.enter() # <container> <?shell:bash> # enter a running container
{
  odocker.exec "$@"
}

odocker.exec.command() # <container> <command...> # run a non-interactive command inside a running container (no TTY)
{
  local container="$1"
  if [ -z "$container" ]; then
    error.log "Usage: odocker exec.command <container> <command...>"
    return 1
  fi
  shift
  if [ -z "$1" ]; then
    error.log "Usage: odocker exec.command <container> <command...>"
    return 1
  fi
  # No -it: callers are scripts/state machines, not humans. "$*" lets callers
  # pass a full shell snippet as one argument; sh -c runs it inside the container.
  docker exec "$container" sh -c "$*"
}
odocker.exec.command.completion.container() {
  private.odocker.container.list.all
}

odocker.stop() # <container> # stop a running container
{
  local container="$1"
  if [ -z "$container" ]; then
    echo "Usage: odocker stop <container>"
    return 1
  fi

  docker stop "$container"
  local rc=$?
  if [ $rc -eq 0 ]; then
    success.log "Container $container stopped"
  else
    error.log "Failed to stop $container"
  fi
  return $rc
}

odocker.log() # <container> <?lines:50> # show container logs
{
  local container="$1"
  if [ -z "$container" ]; then
    echo "Usage: odocker log <container> <?lines:50>"
    return 1
  fi
  shift
  local lines="${1:-50}"

  docker logs --tail "$lines" "$container"
}
odocker.log.completion.container() {
  private.odocker.container.list.all
}

odocker.container.remove() # <container> # remove a stopped container
{
  local container="$1"
  if [ -z "$container" ]; then
    echo "Usage: odocker container.remove <container>"
    return 1
  fi

  docker rm "$container"
  local rc=$?
  if [ $rc -eq 0 ]; then
    success.log "Container $container removed"
  else
    error.log "Failed to remove $container (running? use odocker stop first)"
  fi
  return $rc
}

odocker.container.remove.completion.container() {
  private.odocker.container.list.all
}

odocker.rm() # <container> # [deprecated: use odocker.container.remove] remove a stopped container
{ odocker.container.remove "$@"; }

odocker.rm.completion.container() {
  private.odocker.container.list.all
}

odocker.create() # <image> <?name> # create container without starting
{
  local image="$1"
  if [ -z "$image" ]; then
    echo "Usage: odocker create <image> <?name>"
    return 1
  fi
  shift
  local name="$1"

  local nameOpt=""
  if [ -n "$name" ]; then
    nameOpt="--name $name"
  fi

  local cid
  cid=$(docker create $nameOpt "$image")
  local rc=$?
  if [ $rc -eq 0 ]; then
    local cname
    cname=$(private.odocker.container.name "$cid")
    success.log "Container $cname (${cid:0:12}) created from $image"
  else
    error.log "Create failed with exit code $rc"
  fi
  return $rc
}

odocker.image.remove() # <image> # remove an image
{
  local image="$1"
  if [ -z "$image" ]; then
    echo "Usage: odocker image.remove <image>"
    return 1
  fi

  docker rmi "$image"
  local rc=$?
  if [ $rc -eq 0 ]; then
    success.log "Image $image removed"
  else
    error.log "Failed to remove image $image (in use?)"
  fi
  return $rc
}

odocker.image.remove.completion.image() {
  private.odocker.image.list
}

odocker.rmi() # <image> # [deprecated: use odocker.image.remove] remove an image
{ odocker.image.remove "$@"; }

odocker.rmi.completion.image() {
  private.odocker.image.list
}

odocker.clone() # <container> <?portOrOffset:0> <?docker> # clone a container with its filesystem state onto different ports
{
  local container="$1"
  if [ -z "$container" ]; then
    echo "Usage: odocker clone <container> <?portOrOffset:0> <?docker>"
    echo ""
    odocker.container.list
    return 1
  fi
  shift
  local rawPortArg="${1:-0}"
  shift 2>/dev/null
  local dockerArg="$1"
  local portOffset=$(private.odocker.port.offset "$rawPortArg")

  # Verify source container exists
  if ! docker container inspect "$container" >/dev/null 2>&1; then
    error.log "Container not found: $container"
    return 1
  fi

  # Step 1: Commit container filesystem to a snapshot image
  local cloneImage="clone_${container}"
  console.log "Committing $container to image $cloneImage..."
  docker commit "$container" "$cloneImage" >/dev/null 2>&1
  local rc=$?
  if [ $rc -ne 0 ]; then
    error.log "Failed to commit container $container"
    return $rc
  fi

  # Step 2: Run new container from snapshot
  local exposedPorts
  exposedPorts=$(docker inspect --format '{{json .Config.ExposedPorts}}' "$cloneImage" 2>/dev/null)
  if echo "$exposedPorts" | grep -q '"22/tcp"'; then
    odocker.run.sshd "$cloneImage" "" "$rawPortArg" "$dockerArg"
    rc=$?
  else
    local dockerOpt=$(private.odocker.docker.socket.opt)
    local cid
    cid=$(docker run -d $dockerOpt "$cloneImage")
    rc=$?
    if [ $rc -eq 0 ]; then
      local cname
      cname=$(private.odocker.container.name "$cid")
      success.log "Container $cname (${cid:0:12}) cloned from $container"
    fi
  fi

  # Step 3: Clone volume data from source container
  if [ $rc -eq 0 ]; then
    local cloneCid
    cloneCid=$(docker ps -lq --filter "ancestor=$cloneImage" 2>/dev/null)
    if [ -n "$cloneCid" ]; then
      local mounts
      mounts=$(docker inspect --format '{{range .Mounts}}{{.Destination}}{{"\n"}}{{end}}' "$container" 2>/dev/null)
      if [ -n "$mounts" ]; then
        console.log "Cloning volume data..."
        local mountPath
        while IFS= read -r mountPath; do
          [ -z "$mountPath" ] && continue
          docker cp "$container":"$mountPath" - 2>/dev/null | docker cp - "$cloneCid":/ 2>/dev/null
          if [ $? -eq 0 ]; then
            console.log "  Cloned volume: $mountPath"
          else
            error.log "  Failed to clone volume: $mountPath"
          fi
        done <<< "$mounts"
      fi
    fi
  fi

  if [ $rc -eq 0 ]; then
    success.log "Cloned $container (offset: $portOffset)"
  else
    # Clean up snapshot image on failure
    docker rmi "$cloneImage" >/dev/null 2>&1
    error.log "Clone failed with exit code $rc"
  fi
  return $rc
}
odocker.clone.completion.container() {
  private.odocker.container.list.all
}

odocker.install() # <container> # install Docker CLI inside a running container
{
  local container="$1"
  if [ -n "$1" ]; then
    shift
  else
    error.log "no container was specified"
    echo "Usage: odocker install <container>"
    return 1
  fi

  # Verify container exists and is running
  local state
  state=$(docker inspect --format '{{.State.Status}}' "$container" 2>/dev/null)
  if [ -z "$state" ]; then
    error.log "Container not found: $container"
    return 1
  fi
  if [ "$state" != "running" ]; then
    error.log "Container $container is not running (state: $state)"
    return 1
  fi

  # Verify Docker socket is mounted
  local socketMounted
  socketMounted=$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/var/run/docker.sock"}}yes{{end}}{{end}}' "$container" 2>/dev/null)
  if [ "$socketMounted" != "yes" ]; then
    error.log "Docker socket not mounted in $container. Recreate with 'docker' parameter."
    return 1
  fi

  # Detect OS via package manager and install Docker CLI
  console.log "Installing Docker CLI in $container..."
  local rc
  if docker exec "$container" sh -c 'command -v apk' >/dev/null 2>&1; then
    info.log "Detected Alpine (apk)"
    docker exec "$container" apk add --no-cache docker-cli util-linux-misc
    rc=$?
  elif docker exec "$container" sh -c 'command -v apt-get' >/dev/null 2>&1; then
    info.log "Detected Debian/Ubuntu (apt-get)"
    docker exec "$container" sh -c 'apt-get update && apt-get install -y docker.io bsdextrautils'
    rc=$?
  elif docker exec "$container" sh -c 'command -v dnf' >/dev/null 2>&1; then
    info.log "Detected RHEL/Alma/Fedora (dnf)"
    docker exec "$container" sh -c 'dnf install -y yum-utils && dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo && dnf install -y docker-ce-cli'
    rc=$?
  elif docker exec "$container" sh -c 'command -v yum' >/dev/null 2>&1; then
    info.log "Detected CentOS/older RHEL (yum)"
    docker exec "$container" sh -c 'yum install -y yum-utils && yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo && yum install -y docker-ce-cli'
    rc=$?
  else
    error.log "Unsupported OS in $container: no known package manager found"
    return 1
  fi

  if [ $rc -eq 0 ]; then
    success.log "Docker CLI installed in $container"
    # Grant non-root users access to the bind-mounted Docker socket.
    # Strategy: find or create a group matching the socket's GID, then add users to it.
    # Never chgrp the socket — it's bind-mounted from the host and changing it breaks host perms.
    local socketGid
    socketGid=$(docker exec "$container" stat -c "%g" /var/run/docker.sock 2>/dev/null)
    if [ -z "$socketGid" ]; then
      info.log "Docker socket not found in $container — skipping group setup"
    else
      # Find or create a group matching the socket's GID
      local socketGroup
      socketGroup=$(docker exec "$container" sh -c "getent group $socketGid 2>/dev/null | cut -d: -f1")
      if [ -z "$socketGroup" ]; then
        # No group with this GID — align existing docker group or create one
        local existingGid
        existingGid=$(docker exec "$container" sh -c "getent group docker 2>/dev/null | cut -d: -f3")
        if [ -n "$existingGid" ] && [ "$existingGid" != "$socketGid" ]; then
          # docker group exists with wrong GID — fix it
          docker exec "$container" sh -c "groupmod -g $socketGid docker 2>/dev/null || { delgroup docker 2>/dev/null; addgroup -g $socketGid docker 2>/dev/null; }"
        else
          docker exec "$container" sh -c "groupadd -g $socketGid docker 2>/dev/null || addgroup -g $socketGid docker 2>/dev/null"
        fi
        socketGroup="docker"
      fi
      # Add all non-system users to the socket's group
      local ooshDir
      ooshDir=$(docker exec "$container" bash -c 'source ~/config/user.env 2>/dev/null; echo "$OOSH_DIR"' 2>/dev/null)
      local u
      for u in $(docker exec "$container" awk -F: '$3 >= 1000 && $3 < 65534 {print $1}' /etc/passwd); do
        if [ -n "$ooshDir" ] && docker exec "$container" test -f "$ooshDir/user" 2>/dev/null; then
          docker exec "$container" bash -c "source ~/config/user.env 2>/dev/null; LOG_LEVEL=0 \$OOSH_DIR/user group.add $socketGroup $u"
        else
          docker exec "$container" sh -c "usermod -aG $socketGroup $u 2>/dev/null || addgroup $u $socketGroup 2>/dev/null"
        fi
      done
    fi
  else
    error.log "Docker CLI installation failed in $container (exit code: $rc)"
  fi
  return $rc
}
odocker.install.completion.container() {
  private.odocker.container.list.running
}

# ─────────────────────────────────────────────────────────────────────────────
# DOCKER COMPOSE
# ─────────────────────────────────────────────────────────────────────────────

private.odocker.compose.file.in() {
  local dir="$1"
  local f
  for f in compose.yaml compose.yml docker-compose.yml docker-compose.yaml; do
    if [ -f "$dir/$f" ]; then
      echo "$dir/$f"
      return 0
    fi
  done
  return 1
}

private.odocker.compose.file.find() {
  local dir="${1:-}"
  # If explicit dir given, check only there
  if [ -n "$dir" ]; then
    private.odocker.compose.file.in "$dir" && return 0
    return 1
  fi
  # 1. Current directory
  private.odocker.compose.file.in "." && return 0
  # 2. Workspace directory
  if [ -d "$ODOCKER_WORKSPACES" ]; then
    private.odocker.compose.file.in "$ODOCKER_WORKSPACES" && return 0
  fi
  # 3. From running compose containers (docker inspect label)
  local firstContainer
  firstContainer=$(docker ps -q 2>/dev/null | head -1)
  if [ -n "$firstContainer" ]; then
    local composeDir
    composeDir=$(docker inspect --format '{{index .Config.Labels "com.docker.compose.project.working_dir"}}' "$firstContainer" 2>/dev/null)
    if [ -n "$composeDir" ] && [ "$composeDir" != "<no value>" ]; then
      private.odocker.compose.file.in "$composeDir" && return 0
    fi
  fi
  return 1
}

private.odocker.compose.services() {
  local composeFile
  composeFile=$(private.odocker.compose.file.find 2>/dev/null)
  if [ -n "$composeFile" ]; then
    docker compose -f "$composeFile" config --services 2>/dev/null
  fi
}

odocker.compose() # <?service> # show compose status or service details
{
  local service="$1"
  local composeFile
  composeFile=$(private.odocker.compose.file.find)
  if [ $? -ne 0 ]; then
    error.log "No compose file found"
    return 1
  fi

  if [ -n "$service" ]; then
    docker compose -f "$composeFile" ps "$service"
  else
    console.log "Compose file: $composeFile"
    docker compose -f "$composeFile" ps
  fi
}
odocker.compose.completion.service() {
  private.odocker.compose.services
  private.odocker.container.list.running
}

odocker.up() # <container> <?portOrOffset:0> <?docker> # start a container, run an image, or start a compose service (offset or SSH port)
{
  local container="$1"
  if [ -z "$container" ]; then
    echo "Usage: odocker up <container|image|service> <?portOrOffset:0> <?docker>"
    echo ""
    odocker.container.list
    echo ""
    odocker.image.list
    return 0
  fi
  shift
  local rawPortArg="${1:-0}"
  shift 2>/dev/null
  local dockerArg="$1"
  local portOffset=$(private.odocker.port.offset "$rawPortArg")

  # Try as docker container first (container inspect, not generic inspect)
  if docker container inspect "$container" >/dev/null 2>&1; then
    # If portOffset requested but container has no port bindings, offer to recreate
    local existingPorts
    existingPorts=$(private.odocker.container.ports "$container")
    if [ "$portOffset" -ne 0 ] && [ -z "$existingPorts" ]; then
      local image
      image=$(docker inspect --format '{{.Config.Image}}' "$container" 2>/dev/null)
      echo "Container $container has no port bindings."
      read -p "Reset from $image with offset $portOffset? (y/N) " confirm
      if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then
        docker rm -f "$container" 2>/dev/null
        odocker.run.sshd "$image" "$container" "$rawPortArg" "$dockerArg"
        return $?
      else
        console.log "Aborted"
        return 0
      fi
    fi
    console.log "Starting container: $container"
    docker start "$container"
  elif docker image inspect "$container" >/dev/null 2>&1; then
    # Try as image — use run.sshd if image exposes port 22, else run detached
    local exposedPorts
    exposedPorts=$(docker inspect --format '{{json .Config.ExposedPorts}}' "$container" 2>/dev/null)
    if echo "$exposedPorts" | grep -q '"22/tcp"'; then
      odocker.run.sshd "$container" "" "$rawPortArg" "$dockerArg"
    else
      console.log "Running new container from image: $container (detached)"
      local dockerOpt=$(private.odocker.docker.socket.opt)
      local cid
      cid=$(docker run -d $dockerOpt "$container")
      local rc=$?
      if [ $rc -eq 0 ]; then
        local cname
        cname=$(private.odocker.container.name "$cid")
        success.log "Container $cname (${cid:0:12}) started"
      fi
    fi
    return $?
  else
    # Try as compose service
    local composeFile
    composeFile=$(private.odocker.compose.file.find)
    if [ $? -eq 0 ]; then
      console.log "Starting compose service: $container"
      docker compose -f "$composeFile" up -d "$container"
    else
      error.log "Not found as container, image, or compose service: $container"
      return 1
    fi
  fi
  local rc=$?
  if [ $rc -eq 0 ]; then
    success.log "Started: $container"
  else
    error.log "Start failed with exit code $rc"
  fi
  return $rc
}
odocker.up.completion.container() {
  private.odocker.container.list.all
  private.odocker.image.list
  private.odocker.compose.services 2>/dev/null
}

odocker.down() # <container> # stop a container or compose service
{
  local container="$1"
  if [ -z "$container" ]; then
    echo "Usage: odocker down <container|service>"
    echo ""
    odocker.container.list
    return 0
  fi

  # Try as running docker container first (container inspect, not generic inspect)
  if docker container inspect "$container" >/dev/null 2>&1; then
    console.log "Stopping container: $container"
    docker stop "$container"
  else
    # Try as compose service
    local composeFile
    composeFile=$(private.odocker.compose.file.find)
    if [ $? -eq 0 ]; then
      console.log "Stopping compose service: $container"
      docker compose -f "$composeFile" stop "$container"
    else
      error.log "Container or service not found: $container"
      return 1
    fi
  fi
  local rc=$?
  if [ $rc -eq 0 ]; then
    success.log "Stopped: $container"
  else
    error.log "Stop failed with exit code $rc"
  fi
  return $rc
}
odocker.down.completion.container() {
  private.odocker.container.list.running
  private.odocker.compose.services 2>/dev/null
}

# ─────────────────────────────────────────────────────────────────────────────
# STATUS & MAINTENANCE
# ─────────────────────────────────────────────────────────────────────────────

odocker.status() # # show Docker overview: images, containers, disk usage
{
  console.log "=== Running Containers ==="
  {
    printf "CONTAINER ID\tNAMES\tIMAGE\tSTATUS\tPORTS\n"
    local id name image status ports
    while IFS=$'\t' read -r id name image status; do
      ports=$(private.odocker.container.ports "$name")
      printf "%s\t%s\t%s\t%s\t%s\n" "$id" "$name" "$image" "$status" "$ports"
    done < <(docker ps --format '{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}' 2>/dev/null)
  } | column -t -s $'\t'
  echo ""

  console.log "=== Stopped Containers ==="
  {
    printf "CONTAINER ID\tNAMES\tIMAGE\tSTATUS\tPORTS\n"
    while IFS=$'\t' read -r id name image status; do
      ports=$(private.odocker.container.ports "$name")
      printf "%s\t%s\t%s\t%s\t%s\n" "$id" "$name" "$image" "$status" "$ports"
    done < <(docker ps -a --filter "status=exited" --format '{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}' 2>/dev/null)
  } | column -t -s $'\t'
  echo ""

  console.log "=== Images ==="
  docker images --format 'table {{.Repository}}\t{{.Tag}}\t{{.Size}}' 2>/dev/null
  echo ""

  console.log "=== Disk Usage ==="
  docker system df 2>/dev/null
}

odocker.lifecycle() # # check health of containers, images, and compose services
{
  console.log "=== Container Health ==="
  {
    printf "CONTAINER ID\tCONTAINER\tIMAGE\tHEALTH\tPORTS\n"
    local id container image health ports
    while IFS=$'\t' read -r id container; do
      [ -z "$container" ] && continue
      image=$(docker inspect --format '{{.Config.Image}}' "$container" 2>/dev/null)
      health=$(docker inspect --format '{{.State.Health.Status}}' "$container" 2>/dev/null)
      if [ -z "$health" ] || [ "$health" = "<no value>" ]; then
        health="no healthcheck"
      fi
      ports=$(private.odocker.container.ports "$container")
      printf "%s\t%s\t%s\t%s\t%s\n" "$id" "$container" "$image" "$health" "$ports"
    done < <(docker ps --format '{{.ID}}\t{{.Names}}' 2>/dev/null)
  } | column -t -s $'\t'
  echo ""

  console.log "=== Stopped Containers ==="
  local name status
  local stopped
  stopped=$(docker ps -a --filter "status=exited" --format '{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}' 2>/dev/null)
  if [ -n "$stopped" ]; then
    {
      printf "CONTAINER ID\tCONTAINER\tIMAGE\tSTATUS\tPORTS\n"
      echo "$stopped" | while IFS=$'\t' read -r id name image status; do
        ports=$(private.odocker.container.ports "$name")
        printf "%s\t%s\t%s\t%s\t%s\n" "$id" "$name" "$image" "$status" "$ports"
      done
    } | column -t -s $'\t'
  else
    echo "  (none)"
  fi
  echo ""

  console.log "=== Images ==="
  docker images --format '  {{.Repository}}:{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}' 2>/dev/null | grep -v '<none>' | column -t -s $'\t'
  echo ""

  console.log "=== Dangling Images ==="
  local dangling
  dangling=$(docker images --filter "dangling=true" --format '{{.ID}} {{.CreatedSince}}' 2>/dev/null)
  if [ -n "$dangling" ]; then
    echo "$dangling" | while read -r id age; do
      printf "  %-20s %s\n" "$id" "$age"
    done
  else
    echo "  (none)"
  fi
  echo ""

  console.log "=== Compose Services ==="
  local composeFile
  composeFile=$(private.odocker.compose.file.find 2>/dev/null)
  if [ -n "$composeFile" ]; then
    docker compose -f "$composeFile" ps 2>/dev/null
  else
    echo "  (no compose file found)"
  fi
  echo ""

  console.log "=== Disk Usage ==="
  docker system df 2>/dev/null
}

odocker.disk() # # show Docker disk usage details
{
  docker system df -v 2>/dev/null
}

odocker.prune() # <container> # remove a stopped container and its image
{
  local container="$1"

  if [ -z "$container" ]; then
    echo "Usage: odocker prune <container>"
    echo ""
    odocker.container.list
    return 1
  fi

  # Selective prune: remove container and its image
  local image
  image=$(docker inspect --format '{{.Config.Image}}' "$container" 2>/dev/null)

  # Remove the container
  docker rm -f "$container" 2>/dev/null
  local rc=$?
  if [ $rc -eq 0 ]; then
    success.log "Container $container removed"
  else
    error.log "Failed to remove container: $container"
    return $rc
  fi

  # Remove the image if no other containers use it
  if [ -n "$image" ]; then
    local others
    others=$(docker ps -a --filter "ancestor=$image" --format '{{.Names}}' 2>/dev/null)
    if [ -z "$others" ]; then
      docker rmi "$image" 2>/dev/null
      if [ $? -eq 0 ]; then
        success.log "Image $image removed"
      else
        error.log "Failed to remove image: $image"
      fi
    else
      console.log "Image $image still in use by: $others"
    fi
  fi
}
odocker.prune.completion.container() {
  private.odocker.container.list.all
  private.odocker.image.list
}

odocker.prune.all() # # full system prune including unused images and volumes
{
  console.log "WARNING: This will remove ALL unused data:"
  console.log "  - All stopped containers"
  console.log "  - All unused images (not just dangling)"
  console.log "  - All unused volumes"
  console.log "  - All unused networks"
  console.log "  - Build cache"
  echo ""
  console.log "Current usage before prune:"
  docker system df 2>/dev/null
  echo ""

  read -p "Continue? (y/N) " confirm
  if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
    console.log "Aborted"
    return 0
  fi

  docker system prune -a --volumes -f 2>/dev/null
  local rc=$?

  if [ $rc -eq 0 ]; then
    success.log "Full prune complete"
    docker system df 2>/dev/null
  else
    error.log "Prune failed with exit code $rc"
  fi
  return $rc
}

# ─────────────────────────────────────────────────────────────────────────────
# USAGE & START
# ─────────────────────────────────────────────────────────────────────────────

odocker.usage()
{
  local this=${0##*/}
  echo "You started"
  echo "$0

  Usage:
  $this: command   Parameter and Description"
  this.help
  echo "

  Examples
    $this workspace.get
    $this workspace.set /path/to/workspaces
    $this workspace.list
    $this container.list
    $this image.list
    $this ps
    $this build nakedUbuntu/20.04.sshd
    $this run.sshd naked_ubuntu_20_04_sshd
    $this exec fervent_ritchie
    $this enter fervent_ritchie
    $this create naked_ubuntu_24_04 mycontainer
    $this up mycontainer
    $this down mycontainer
    $this file.find fervent_ritchie
    $this stop fervent_ritchie
    $this log fervent_ritchie
    $this compose
    $this status
    $this lifecycle
    $this disk
    $this prune
    ----------
  "
}

# ─────────────────────────────────────────────────────────────────────────────
# PARAMETER COMPLETIONS (shared across all methods)
# ─────────────────────────────────────────────────────────────────────────────

odocker.parameter.completion.shell() {
  echo "bash"
  echo "sh"
}

odocker.parameter.completion.workspace() {
  private.odocker.workspace.list
}

odocker.parameter.completion.container() {
  private.odocker.container.list.running
}

odocker.parameter.completion.image() {
  private.odocker.image.list
}

odocker.parameter.completion.service() {
  private.odocker.compose.services
}

odocker.parameter.completion.docker() {
  echo "docker"
}

odocker.start()
{
  source this
  this.start "$@"
}

odocker.start "$@"
