#!/usr/bin/env python3
"""
lcsb-icommands - iRODS Client for Automatic Ingestion
Supports both one-off commands (lcsb-icommands ls) and interactive shell (lcsb-icommands)
Works on Windows, macOS, and Linux.
"""

import argparse
import base64
import json
import os
import sys
import time
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from threading import Thread
from urllib.parse import parse_qs, urlparse

import requests

# ---------------------------
# Config
# ---------------------------
KEYCLOAK_URL = "https://sso.lcsb.uni.lu/realms/main"
CLIENT_ID = "irods-mango-prod01-srv.lcsb.uni.lu"
REDIRECT_URI = "http://localhost:8888/callback"
LCSB_ICOMMANDS_SERVER = "http://irods-mango-prod01-srv.lcsb.uni.lu/lcsb-icommands"
CREDENTIALS_FILE = Path.home() / ".lcsb-icommands" / "credentials.json"

auth_code = None


# ---------------------------
# Headless detection
# ---------------------------
def _is_headless():
    """Return True if running in a headless environment (SSH, HPC, no display).
    Used to auto-select device flow over browser flow.
    """
    if sys.platform == "win32":
        return False
    # Active SSH session
    if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_CLIENT") or os.environ.get("SSH_TTY"):
        return True
    # No graphical display available (Linux/macOS)
    if not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"):
        return True
    return False


# ---------------------------
# PATH auto-setup
# ---------------------------
def _setup_path():
    """
    Ensure ~/.local/bin is on PATH, and clean up any conflicting iRODS aliases.
    Called on first run of any icommand — silently patches ~/.bashrc if needed,
    then prints a one-time notice. No-ops on Windows.
    """
    if sys.platform == "win32":
        return

    local_bin = str(Path.home() / ".local" / "bin")

    # icommand names we manage — detect aliases pointing to old /usr/bin paths
    _ICOMMANDS = ["iinit", "ils", "iput", "iget", "imkdir", "irm", "ipwd",
                  "imeta", "icd", "imv", "icp", "ichmod", "ichksum", "itree", "ienv"]

    # Find the right rc file
    rc_candidates = [
        Path.home() / ".bashrc",
        Path.home() / ".bash_profile",
        Path.home() / ".profile",
    ]
    rc_file = next((p for p in rc_candidates if p.exists()), rc_candidates[0])

    try:
        existing = rc_file.read_text(encoding="utf-8") if rc_file.exists() else ""

        # Remove any lines that alias our icommands to system paths (e.g. old iRODS)
        stale_alias_pattern = [f"alias {cmd}=" for cmd in _ICOMMANDS]
        cleaned_lines = []
        removed_aliases = []
        for line in existing.splitlines():
            stripped = line.strip()
            if any(stripped.startswith(pat) and "/usr/bin/" in stripped for pat in stale_alias_pattern):
                removed_aliases.append(stripped)
                continue
            cleaned_lines.append(line)
        if removed_aliases:
            rc_file.write_text("\n".join(cleaned_lines) + "\n", encoding="utf-8")
            existing = rc_file.read_text(encoding="utf-8")
            print(f"✓ Removed stale iRODS aliases from {rc_file.name}")
            print(f"  Run: unalias {' '.join(c.split('=')[0].replace('alias ','') for c in removed_aliases)}")
    except Exception:
        pass

    try:
        existing = rc_file.read_text(encoding="utf-8") if rc_file.exists() else ""
        changes = []

        # Add PATH entry if missing
        if local_bin not in existing:
            export_line = f'export PATH="{local_bin}:$PATH"'
            changes.append(export_line)

        # Add hash -r if missing
        if "hash -r" not in existing:
            changes.append("hash -r 2>/dev/null || true")

        if changes:
            with open(rc_file, "a", encoding="utf-8") as f:
                f.write(f"\n# lcsb-icommands: PATH and cache setup\n")
                for line in changes:
                    f.write(f"{line}\n")
            if local_bin not in existing:
                print(f"✓ Added {local_bin} to PATH in {rc_file.name}")
            print(f"  Run: source ~/{rc_file.name}  (or open a new terminal)")
    except Exception:
        print(f"  Add to PATH manually: export PATH=\"{local_bin}:$PATH\"")


# ---------------------------
# OAuth Callback
# ---------------------------
class CallbackHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        global auth_code
        params = parse_qs(urlparse(self.path).query)
        if "code" in params:
            auth_code = params["code"][0]
            self.send_response(200)
            self.send_header("Content-type", "text/html")
            self.end_headers()
            self.wfile.write(b"<h1>Authentication Successful</h1>You can close this window.")

    def log_message(self, format, *args):
        pass


# ---------------------------
# Client
# ---------------------------
class LCSBICommandsClient:
    def __init__(self):
        self.token = None
        self.refresh_token = None
        self.username = None
        self.expires_at = 0
        self._completions_cache = {}
        self._cache_ttl = 30
        self._load_credentials()

    # ---------------------------
    # Credentials
    # ---------------------------
    def _load_credentials(self):
        if not CREDENTIALS_FILE.exists():
            return
        try:
            data = json.loads(CREDENTIALS_FILE.read_text())
            self.token = data.get("access_token")
            self.refresh_token = data.get("refresh_token")
            self.username = data.get("username")
            self.expires_at = data.get("expires_at", 0)
        except Exception:
            pass

    def _save_credentials(self, token_json):
        self.token = token_json["access_token"]
        self.refresh_token = token_json.get("refresh_token")
        expires = token_json.get("expires_in", 3600)
        self.expires_at = time.time() + expires

        try:
            payload = self.token.split(".")[1]
            payload += "=" * ((4 - len(payload) % 4) % 4)
            decoded = json.loads(base64.b64decode(payload))
            self.username = decoded.get("preferred_username")
        except Exception:
            pass

        CREDENTIALS_FILE.parent.mkdir(exist_ok=True)
        CREDENTIALS_FILE.write_text(
            json.dumps(
                {
                    "access_token": self.token,
                    "refresh_token": self.refresh_token,
                    "expires_at": self.expires_at,
                    "username": self.username,
                },
                indent=2,
            )
        )

    def _clear_credentials(self):
        self.token = None
        self.refresh_token = None
        self.username = None
        self.expires_at = 0
        if CREDENTIALS_FILE.exists():
            CREDENTIALS_FILE.unlink()

    def _token_valid(self):
        return self.token and time.time() < self.expires_at

    def _ensure_token(self) -> bool:
        if self._token_valid():
            return True

        if self.refresh_token:
            try:
                r = requests.post(
                    f"{LCSB_ICOMMANDS_SERVER}/token-refresh",
                    json={"refresh_token": self.refresh_token},
                    timeout=20,
                )
                if r.status_code == 200:
                    self._save_credentials(r.json())
                    return True
            except Exception:
                pass

        print("✗ Session expired. Run: lcsb-icommands login")
        return False

    def _auth_headers(self):
        return {"Authorization": f"Bearer {self.token}"}

    def _api_get(self, endpoint: str, params: dict = None) -> dict:
        r = requests.get(
            f"{LCSB_ICOMMANDS_SERVER}/{endpoint}",
            headers=self._auth_headers(),
            params=params,
            timeout=30,
        )
        if r.status_code == 401:
            if self._ensure_token():
                r = requests.get(
                    f"{LCSB_ICOMMANDS_SERVER}/{endpoint}",
                    headers=self._auth_headers(),
                    params=params,
                    timeout=30,
                )
            else:
                return {}
        r.raise_for_status()
        return r.json()

    # ---------------------------
    # Tab completion helpers
    # ---------------------------
    def _fetch_ls_items(self, path: str) -> list:
        now = time.time()
        cached = self._completions_cache.get(path)
        if cached and (now - cached["time"]) < self._cache_ttl:
            return cached["items"]

        try:
            if not self._token_valid() and not self._ensure_token():
                return []
            data = self._api_get("ls", {"path": path})
            items = data.get("items", [])
            self._completions_cache[path] = {"items": items, "time": now}
            return items
        except Exception:
            return []

    def get_completions(self, prefix: str) -> list:
        parts = prefix.rstrip("/").rsplit("/", 1)
        if len(parts) == 1:
            parent_path = ""
            partial = parts[0]
        else:
            parent_path = parts[0]
            partial = parts[1]

        items = self._fetch_ls_items(parent_path)
        completions = []
        for item in items:
            name = item["name"]
            if name.lower().startswith(partial.lower()):
                full = f"{parent_path}/{name}" if parent_path else name
                if item["type"] == "collection":
                    full += "/"
                completions.append(full)
        return completions

    # ---------------------------
    # login
    # ---------------------------
    def login(self, token_str: str = None, headless: bool = False):
        """Authenticate. Auto-selects device flow on headless/SSH environments."""
        if token_str:
            self._login_with_token(token_str)
            return

        if headless or _is_headless():
            self._device_login()
        else:
            self._browser_login()

    def _browser_login(self):
        """Standard OAuth2 browser flow (desktop / laptop)."""
        global auth_code
        auth_code = None

        server = HTTPServer(("localhost", 8888), CallbackHandler)
        Thread(target=lambda: server.handle_request(), daemon=True).start()

        auth_url = (
            f"{KEYCLOAK_URL}/protocol/openid-connect/auth"
            f"?client_id={CLIENT_ID}"
            f"&redirect_uri={REDIRECT_URI}"
            f"&response_type=code"
            f"&scope=openid profile email"
            f"&prompt=login"
        )
        print("Starting authentication...")
        print("A browser window will open for login.")
        print(f"Waiting for callback on {REDIRECT_URI}")
        print("Opening browser...")
        webbrowser.open(auth_url)

        start = time.time()
        while auth_code is None and time.time() - start < 120:
            time.sleep(0.5)

        if not auth_code:
            print("✗ Login timed out (2 minutes)")
            return

        print("Exchanging authorization code for token...")
        r = requests.post(f"{LCSB_ICOMMANDS_SERVER}/token-exchange", json={"code": auth_code}, timeout=20)
        r.raise_for_status()
        self._save_credentials(r.json())

        print(f"✓ Authenticated as: {self.username}")
        print(f"✓ Token valid for: ~{r.json().get('expires_in', 3600) // 60} minutes")
        print(f"✓ Credentials saved to: {CREDENTIALS_FILE}")

    def _device_login(self):
        """Keycloak Device Authorization Flow (RFC 8628) for headless/HPC/SSH."""
        print("Starting device authentication (headless mode)...")
        try:
            r = requests.post(f"{LCSB_ICOMMANDS_SERVER}/device-auth", timeout=15)
            r.raise_for_status()
            data = r.json()
        except Exception as e:
            print(f"✗ Could not start device flow: {e}")
            return

        user_code        = data.get("user_code", "")
        verification_uri = data.get("verification_uri_complete") or data.get("verification_uri", "")
        expires_in       = data.get("expires_in", 600)
        interval         = max(data.get("interval", 5), 5)   # poll every N seconds (min 5)
        device_code      = data.get("device_code", "")

        # Print clear instructions
        print()
        print("━" * 58)
        print("  Open this URL on your laptop / desktop browser:")
        print()
        print(f"  {verification_uri}")
        print()
        if not data.get("verification_uri_complete"):
            # URL doesn't include the code — show it separately
            print(f"  Then enter code:  {user_code}")
            print()
        print(f"  Waiting for you to log in… (expires in {expires_in // 60} min)")
        print("━" * 58)
        print()

        deadline = time.time() + expires_in
        while time.time() < deadline:
            time.sleep(interval)
            try:
                poll = requests.post(
                    f"{LCSB_ICOMMANDS_SERVER}/device-poll",
                    json={"device_code": device_code},
                    timeout=15,
                )
                poll.raise_for_status()
                result = poll.json()
            except Exception as e:
                print(f"✗ Poll error: {e}")
                return

            status = result.get("status")

            if status == "ok":
                self._save_credentials(result)
                print(f"✓ Authenticated as: {self.username}")
                print(f"✓ Token valid for: ~{result.get('expires_in', 3600) // 60} minutes")
                print(f"✓ Credentials saved to: {CREDENTIALS_FILE}")
                return
            elif status == "slow_down":
                interval += 5
            elif status == "authorization_pending":
                pass  # still waiting, keep polling
            elif status == "expired":
                print("✗ Device code expired. Run iinit again.")
                return
            elif status == "denied":
                print("✗ Login was denied.")
                return
            else:
                print(f"✗ Unexpected status: {status}")
                return

        print("✗ Login timed out.")

    def _login_with_token(self, token_str: str):
        """Login using a copied credentials JSON or raw token."""
        try:
            # Try parsing as JSON (from 'lcsb-icommands token --json')
            data = json.loads(token_str)
            self.token = data["access_token"]
            self.refresh_token = data.get("refresh_token")
            self.expires_at = data.get("expires_at", time.time() + 3600)
            self.username = data.get("username")
        except (json.JSONDecodeError, KeyError):
            # Treat as raw access token
            self.token = token_str.strip()
            self.refresh_token = None
            self.expires_at = time.time() + 300  # Assume 5 min validity

        # Decode username from JWT if not set
        if not self.username:
            try:
                payload = self.token.split(".")[1]
                payload += "=" * ((4 - len(payload) % 4) % 4)
                decoded = json.loads(base64.b64decode(payload))
                self.username = decoded.get("preferred_username")
            except Exception:
                self.username = "unknown"

        # Save credentials
        CREDENTIALS_FILE.parent.mkdir(exist_ok=True)
        CREDENTIALS_FILE.write_text(
            json.dumps(
                {
                    "access_token": self.token,
                    "refresh_token": self.refresh_token,
                    "expires_at": self.expires_at,
                    "username": self.username,
                },
                indent=2,
            )
        )
        print(f"✓ Authenticated as: {self.username}")
        print(f"✓ Credentials saved to: {CREDENTIALS_FILE}")
        if self.refresh_token:
            print("✓ Refresh token included (session will auto-refresh)")
        else:
            print("⚠ No refresh token — session may expire soon")

    # ---------------------------
    # token (export)
    # ---------------------------
    def export_token(self, as_json: bool = False):
        """Print current token for copying to another machine."""
        if not self.token:
            print("Not logged in. Run: lcsb-icommands login")
            return

        if as_json:
            data = {
                "access_token": self.token,
                "refresh_token": self.refresh_token,
                "expires_at": self.expires_at,
                "username": self.username,
            }
            print(json.dumps(data))
        else:
            print(self.token)

    # ---------------------------
    # logout
    # ---------------------------
    def logout(self):
        self._clear_credentials()
        print("✓ Logged out. Credentials cleared.")

    # ---------------------------
    # whoami
    # ---------------------------
    def whoami(self):
        if not self.token:
            print("Not logged in. Run: lcsb-icommands login")
            return

        print(f"User:    {self.username or 'unknown'}")

        if self._token_valid():
            remaining = int(self.expires_at - time.time())
            mins, secs = divmod(remaining, 60)
            print(f"Token:   valid ({mins}m {secs}s remaining)")
        else:
            print("Token:   expired")
            if self.refresh_token:
                print("Refresh: available (will auto-refresh on next command)")
            else:
                print("Refresh: none — run: login")

        print(f"Server:  {LCSB_ICOMMANDS_SERVER}")
        print(f"Config:  {CREDENTIALS_FILE}")

    # ---------------------------
    # ls
    # ---------------------------
    def ls(self, path: str = ""):
        if not self._ensure_token():
            return

        try:
            data = self._api_get("ls", {"path": path})
            items = data.get("items", [])

            if not items:
                print("(empty)")
                return

            for item in items:
                name = item["name"]
                typ = item["type"]
                if typ == "collection":
                    print(f"  {name}/")
                else:
                    print(f"  {name}")

        except requests.exceptions.HTTPError as e:
            if e.response and e.response.status_code == 404:
                print(f"✗ Path not found: {path}")
            else:
                print(f"✗ Error: {e}")
        except Exception as e:
            print(f"✗ Failed: {e}")

    # ---------------------------
    # upload
    # ---------------------------
    def upload(self, remote_path: str, local_path: str):
        if not self._ensure_token():
            return

        parts = [p for p in remote_path.strip("/").split("/") if p]
        if len(parts) < 2:
            print("✗ Upload path must include project and dataset.")
            print("  Example: upload My_project/test_dataset_01 ./file.txt")
            return

        local = Path(local_path).resolve()
        if not local.exists():
            print(f"✗ Local path not found: {local_path}")
            return

        if local.is_file():
            files_to_upload = [local]
        elif local.is_dir():
            files_to_upload = [f for f in local.rglob("*") if f.is_file()]
            if not files_to_upload:
                print(f"✗ No files found in: {local_path}")
                return
        else:
            print(f"✗ Invalid path: {local_path}")
            return

        print(f"Uploading {len(files_to_upload)} file(s) to {remote_path}...")

        open_files = []
        try:
            multipart = []
            for f in files_to_upload:
                if local.is_dir():
                    rel = str(f.relative_to(local))
                else:
                    rel = f.name
                fh = open(f, "rb")
                open_files.append(fh)
                multipart.append(("files", (rel, fh, "application/octet-stream")))

            r = requests.post(
                f"{LCSB_ICOMMANDS_SERVER}/upload",
                headers=self._auth_headers(),
                data={"path": remote_path},
                files=multipart,
                timeout=300,
            )

            if r.status_code == 401:
                print("✗ Session expired. Run: lcsb-icommands login")
                return
            r.raise_for_status()

            result = r.json()
            uploaded = result.get("files", [])
            for f in uploaded:
                size = f.get("size_bytes", 0)
                sha = f.get("checksum_sha256", "")[:16]
                print(f"  ✓ {f['filename']} ({_human_size(size)}, sha256={sha}...)")

            print(f"\n✓ {len(uploaded)} file(s) uploaded to {remote_path}")

        except requests.exceptions.HTTPError as e:
            detail = ""
            if e.response:
                try:
                    detail = e.response.json().get("detail", "")
                except Exception:
                    detail = e.response.text
            print(f"✗ Upload failed: {detail or e}")
        except Exception as e:
            print(f"✗ Upload failed: {e}")
        finally:
            for fh in open_files:
                fh.close()

    # ---------------------------
    # staging
    # ---------------------------
    def staging(self, landing_dir: str, project_id: str, dataset_id: str):
        """Trigger server-side staging from a landing zone into iRODS."""
        if not self._ensure_token():
            return

        print(f"Staging '{landing_dir}' → project={project_id}, dataset={dataset_id} ...")

        try:
            r = requests.post(
                f"{LCSB_ICOMMANDS_SERVER}/staging",
                headers=self._auth_headers(),
                json={"landing_dir": landing_dir, "project_id": project_id, "dataset_id": dataset_id},
                timeout=300,
            )

            if r.status_code == 401:
                print("✗ Session expired. Run: lcsb-icommands login")
                return
            r.raise_for_status()

            result = r.json()
            files = result.get("files", [])
            for f in files:
                print(f"  ✓ {f}")
            print(f"\n✓ Staging complete — {len(files)} file(s) ingested")

        except requests.exceptions.HTTPError as e:
            detail = ""
            if e.response:
                try:
                    detail = e.response.json().get("detail", "")
                except Exception:
                    detail = e.response.text
            print(f"✗ Staging failed: {detail or e}")
        except Exception as e:
            print(f"✗ Staging failed: {e}")

    # ---------------------------
    # download
    # ---------------------------
    def download(self, remote_path: str, local_path: str):
        if not self._ensure_token():
            return

        parts = [p for p in remote_path.strip("/").split("/") if p]
        local = Path(local_path).resolve()

        if len(parts) >= 3:
            self._download_file(remote_path, local)
        elif len(parts) == 2:
            self._download_dataset(remote_path, local)
        else:
            print("✗ Download path must include at least project/dataset.")
            print("  Example: download My_project/dataset_01 ./output/")
            print("  Example: download My_project/dataset_01/file.txt ./")

    def _download_file(self, remote_path: str, local: Path):
        filename = remote_path.split("/")[-1]

        if local.is_dir() or str(local).endswith(os.sep) or str(local).endswith("/"):
            local.mkdir(parents=True, exist_ok=True)
            dest = local / filename
        else:
            local.parent.mkdir(parents=True, exist_ok=True)
            dest = local

        print(f"  Downloading {filename}...")

        try:
            r = requests.get(
                f"{LCSB_ICOMMANDS_SERVER}/download",
                headers=self._auth_headers(),
                params={"path": remote_path},
                timeout=300,
                stream=True,
            )
            if r.status_code == 401:
                print("✗ Session expired. Run: lcsb-icommands login")
                return
            if r.status_code == 404:
                print(f"✗ File not found: {remote_path}")
                return
            r.raise_for_status()

            with open(dest, "wb") as f:
                for chunk in r.iter_content(chunk_size=8192):
                    f.write(chunk)

            size = dest.stat().st_size
            print(f"  ✓ {filename} ({_human_size(size)}) → {dest}")

        except requests.exceptions.HTTPError as e:
            print(f"✗ Download failed: {e}")
        except Exception as e:
            print(f"✗ Download failed: {e}")

    def _download_dataset(self, remote_path: str, local: Path):
        local.mkdir(parents=True, exist_ok=True)
        print(f"Listing files in {remote_path}...")

        try:
            data = self._api_get("ls", {"path": remote_path})
            items = data.get("items", [])
            files = [i for i in items if i["type"] == "data_object"]

            if not files:
                print("(no files in dataset)")
                return

            print(f"Downloading {len(files)} file(s)...\n")
            for item in files:
                file_path = f"{remote_path}/{item['name']}"
                self._download_file(file_path, local)

            print(f"\n✓ {len(files)} file(s) downloaded to {local}")

        except Exception as e:
            print(f"✗ Download failed: {e}")

    # ---------------------------
    # info
    # ---------------------------
    def info(self, path: str):
        if not self._ensure_token():
            return

        try:
            data = self._api_get("info", {"path": path})
            obj_type = data.get("type", "unknown")
            irods_path = data.get("irods_path", "")
            info = data.get("info", {})

            print(f"  Path:       {path}")
            print(f"  Type:       {obj_type}")
            print(f"  iRODS path: {irods_path}")

            if isinstance(info, dict):
                for key, val in info.items():
                    if key == "irods_response":
                        continue
                    if isinstance(val, dict):
                        print(f"  {key}:")
                        for k2, v2 in val.items():
                            print(f"    {k2}: {v2}")
                    elif isinstance(val, list):
                        print(f"  {key}:")
                        for item in val:
                            print(f"    - {item}")
                    else:
                        print(f"  {key}: {val}")

        except requests.exceptions.HTTPError as e:
            if e.response and e.response.status_code == 404:
                print(f"✗ Path not found: {path}")
            else:
                print(f"✗ Error: {e}")
        except Exception as e:
            print(f"✗ Failed: {e}")


# ---------------------------
# Interactive Shell
# ---------------------------
class LCSBICommandsShell:
    COMMANDS = ["login", "logout", "whoami", "token", "ls", "lls", "lcd", "lpwd", "upload", "download", "info", "help", "exit", "quit"]

    def __init__(self):
        self.client = LCSBICommandsClient()
        self._setup_readline()

    def _setup_readline(self):
        try:
            try:
                import readline
            except ImportError:
                import pyreadline3 as readline

            self._readline = readline

            def completer(text, state):
                line = readline.get_line_buffer().lstrip()
                if state == 0:
                    completer.matches = self._get_matches(line, text)
                if state < len(completer.matches):
                    return completer.matches[state]
                return None

            completer.matches = []
            readline.set_completer(completer)
            readline.set_completer_delims(" ")
            readline.parse_and_bind("tab: complete")

        except ImportError:
            self._readline = None
            print("  (Tip: install pyreadline3 for tab completion: pip install pyreadline3)")

    def _get_matches(self, line: str, text: str) -> list:
        parts = line.split()

        # Complete command name
        if len(parts) == 0 or (len(parts) == 1 and not line.endswith(" ")):
            prefix = parts[0] if parts else ""
            return [c + " " for c in self.COMMANDS if c.startswith(prefix)]

        cmd = parts[0]

        # Complete remote paths for ls, info
        if cmd in ("ls", "info"):
            path_prefix = parts[1] if len(parts) > 1 and not line.endswith(" ") else ""
            return self.client.get_completions(path_prefix)

        # Upload: first arg = remote path (tab complete), second arg = local path
        if cmd == "upload":
            if len(parts) == 2 and not line.endswith(" "):
                return self.client.get_completions(parts[1])
            if (len(parts) == 2 and line.endswith(" ")) or (len(parts) == 3 and not line.endswith(" ")):
                # Local file completion
                local_prefix = parts[2] if len(parts) == 3 else ""
                return self._local_path_completions(local_prefix)
            return []

        # Download: first arg = remote path (tab complete), second arg = local path
        if cmd == "download":
            if len(parts) == 2 and not line.endswith(" "):
                return self.client.get_completions(parts[1])
            if (len(parts) == 2 and line.endswith(" ")) or (len(parts) == 3 and not line.endswith(" ")):
                local_prefix = parts[2] if len(parts) == 3 else ""
                return self._local_path_completions(local_prefix)
            return []

        # lls: local path completion
        if cmd == "lls":
            local_prefix = parts[1] if len(parts) > 1 and not line.endswith(" ") else ""
            return self._local_path_completions(local_prefix)

        # lcd: local directory completion
        if cmd == "lcd":
            local_prefix = parts[1] if len(parts) > 1 and not line.endswith(" ") else ""
            return self._local_path_completions(local_prefix, dirs_only=True)

        return []

    def _local_path_completions(self, prefix: str, dirs_only: bool = False) -> list:
        """Complete local file/directory paths."""
        try:
            if not prefix:
                p = Path(".")
                partial = ""
            else:
                p = Path(prefix)
                if p.is_dir() and prefix.endswith(("/", "\\")):
                    partial = ""
                else:
                    p = p.parent
                    partial = Path(prefix).name

            completions = []
            if p.exists():
                for item in sorted(p.iterdir()):
                    if item.name.startswith("."):
                        continue
                    if item.name.lower().startswith(partial.lower()):
                        if dirs_only and not item.is_dir():
                            continue
                        rel = str(item)
                        if item.is_dir():
                            rel += os.sep
                        completions.append(rel)
            return completions
        except Exception:
            return []

    def _print_help(self):
        print()
        print("  lcsb-icommands Commands:")
        print("  ─────────────────────────────────────────────────────────")
        print("  login [--token <token>] [--headless]       Authenticate (--headless for SSH/HPC)")
        print("  logout                                   Clear credentials")
        print("  whoami                                   Show user & token info")
        print("  token [--json]                           Print token (for copying to HPC/SSH)")
        print()
        print("  Remote (iRODS):")
        print("  ls [path]                                List projects/datasets/files")
        print("  upload <project/dataset> <local_path>    Upload files")
        print("  download <remote_path> <local_path>      Download files")
        print("  info <path>                              Show metadata")
        print()
        print("  Local:")
        print("  lls [path]                               List local files")
        print("  lcd <path>                               Change local directory")
        print("  lpwd                                     Show local working directory")
        print()
        print("  help                                     Show this help")
        print("  exit / quit                              Exit lcsb-icommands shell")
        print()
        print("  Tab completion works for commands, remote paths, and local paths.")
        print()

    def _get_prompt(self) -> str:
        cwd = Path.cwd().name or str(Path.cwd())
        if self.client.username and self.client._token_valid():
            return f"\033[32mlcsb-icommands ({self.client.username})\033[0m {cwd}> "
        elif self.client.username:
            return f"\033[33mlcsb-icommands ({self.client.username} ⚠)\033[0m {cwd}> "
        else:
            return f"\033[31mlcsb-icommands\033[0m {cwd}> "

    def run(self):
        print()
        print("  ╔════════════════════════════════════════════════╗")
        print("  ║  lcsb-icommands - iRODS Client for Automatic Ingestion  ║")
        print("  ║  Type 'help' for commands, 'exit' to quit     ║")
        print("  ╚════════════════════════════════════════════════╝")
        print()

        if self.client.username and self.client._token_valid():
            remaining = int(self.client.expires_at - time.time())
            mins = remaining // 60
            print(f"  Logged in as: {self.client.username} ({mins}m remaining)")
        elif self.client.username and self.client.refresh_token:
            print(f"  Session for {self.client.username} (will auto-refresh)")
        else:
            print("  Not logged in. Type 'lcsb-icommands login' to authenticate.")
        print()

        while True:
            try:
                line = input(self._get_prompt()).strip()
            except (EOFError, KeyboardInterrupt):
                print("\nGoodbye!")
                break

            if not line:
                continue

            # Split preserving Windows paths (don't use shlex on Windows)
            if sys.platform == "win32":
                parts = line.split()
            else:
                try:
                    import shlex
                    parts = shlex.split(line)
                except ValueError:
                    parts = line.split()

            cmd = parts[0].lower()
            args = parts[1:]

            if cmd in ("exit", "quit"):
                print("Goodbye!")
                break
            elif cmd == "help":
                self._print_help()
            elif cmd == "login":
                token_arg = None
                headless_arg = "--headless" in args
                if len(args) >= 2 and args[0] in ("--token", "-t"):
                    token_arg = args[1]
                self.client.login(token_str=token_arg, headless=headless_arg)
            elif cmd == "token":
                as_json = "--json" in args or "-j" in args
                self.client.export_token(as_json=as_json)
            elif cmd == "logout":
                self.client.logout()
            elif cmd == "whoami":
                self.client.whoami()
            elif cmd == "ls":
                self.client.ls(args[0] if args else "")
            elif cmd == "lls":
                self._local_ls(args[0] if args else ".")
            elif cmd == "lcd":
                self._local_cd(args[0] if args else str(Path.home()))
            elif cmd == "lpwd":
                print(f"  {Path.cwd()}")
            elif cmd == "upload":
                if len(args) < 2:
                    print("Usage: upload <project/dataset> <local_path>")
                else:
                    self.client.upload(args[0], args[1])
            elif cmd == "download":
                if len(args) < 2:
                    print("Usage: download <remote_path> <local_path>")
                else:
                    self.client.download(args[0], args[1])
            elif cmd == "info":
                if not args:
                    print("Usage: info <path>")
                else:
                    self.client.info(args[0])
            else:
                print(f"Unknown command: {cmd}. Type 'help' for available commands.")

    def _local_ls(self, path: str = "."):
        """List local directory contents."""
        try:
            p = Path(path).resolve()
            if not p.exists():
                print(f"✗ Path not found: {path}")
                return
            if not p.is_dir():
                print(f"  {p.name}  ({_human_size(p.stat().st_size)})")
                return

            items = sorted(p.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
            if not items:
                print("(empty)")
                return

            for item in items:
                if item.name.startswith("."):
                    continue
                if item.is_dir():
                    print(f"  {item.name}/")
                else:
                    size = _human_size(item.stat().st_size)
                    print(f"  {item.name:<40} {size}")

        except PermissionError:
            print(f"✗ Permission denied: {path}")
        except Exception as e:
            print(f"✗ Error: {e}")

    def _local_cd(self, path: str):
        """Change local working directory."""
        try:
            p = Path(path).resolve()
            if not p.exists():
                print(f"✗ Path not found: {path}")
                return
            if not p.is_dir():
                print(f"✗ Not a directory: {path}")
                return
            os.chdir(p)
            print(f"  {p}")
        except PermissionError:
            print(f"✗ Permission denied: {path}")
        except Exception as e:
            print(f"✗ Error: {e}")


# ---------------------------
# Helpers
# ---------------------------
def _human_size(size_bytes: int) -> str:
    for unit in ["B", "KB", "MB", "GB"]:
        if size_bytes < 1024:
            return f"{size_bytes:.1f} {unit}" if unit != "B" else f"{size_bytes} {unit}"
        size_bytes /= 1024
    return f"{size_bytes:.1f} TB"


# ---------------------------
# CLI Entry Point
# ---------------------------
def main():
    # No arguments → interactive shell
    if len(sys.argv) == 1:
        shell = LCSBICommandsShell()
        shell.run()
        return

    parser = argparse.ArgumentParser(
        prog="lcsb-icommands",
        description="lcsb-icommands - iRODS Client for Automatic Ingestion",
        epilog="""icommand-compatible aliases:
  iinit                 Authenticate (= lcsb-icommands login)
  ils [path]            List collections (= lcsb-icommands ls)
  iput <local> <remote> Upload files (= lcsb-icommands upload)
  iget <remote> [local] Download files (= lcsb-icommands download)
  imkdir <path>         Create collection
  irm <path>           Remove data object/collection
  ipwd                  Print working collection
  imeta ls -d <path>    Show metadata (= lcsb-icommands info)""",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    sub = parser.add_subparsers(dest="cmd")

    sub.add_parser("logout", help="Clear saved credentials")
    sub.add_parser("whoami", help="Show current user and token status")
    sub.add_parser("shell", help="Launch interactive lcsb-icommands shell")

    login_p = sub.add_parser("login", help="Authenticate with Keycloak")
    login_p.add_argument("--token", "-t", help="Login with copied token (JSON or raw)")

    token_p = sub.add_parser("token", help="Print current token for copying to another machine")
    token_p.add_argument("--json", "-j", action="store_true", dest="as_json", help="Output as JSON (includes refresh token)")

    ls_p = sub.add_parser("ls", help="List projects, datasets, or files")
    ls_p.add_argument("path", nargs="?", default="", help="Path (e.g. My_project/dataset_01)")

    up_p = sub.add_parser("upload", help="Upload files to a dataset")
    up_p.add_argument("remote_path", help="Remote path: project/dataset")
    up_p.add_argument("local_path", help="Local file or folder to upload")

    dl_p = sub.add_parser("download", help="Download files from a dataset")
    dl_p.add_argument("remote_path", help="Remote path: project/dataset[/file]")
    dl_p.add_argument("local_path", help="Local destination path")

    info_p = sub.add_parser("info", help="Show metadata for a file or collection")
    info_p.add_argument("path", help="Remote path to inspect")

    args = parser.parse_args()

    if not args.cmd:
        parser.print_help()
        return

    if args.cmd == "shell":
        shell = LCSBICommandsShell()
        shell.run()
        return

    client = LCSBICommandsClient()

    if args.cmd == "login":
        client.login(token_str=args.token)
    elif args.cmd == "token":
        client.export_token(as_json=args.as_json)
    elif args.cmd == "logout":
        client.logout()
    elif args.cmd == "whoami":
        client.whoami()
    elif args.cmd == "ls":
        client.ls(args.path)
    elif args.cmd == "upload":
        client.upload(args.remote_path, args.local_path)
    elif args.cmd == "download":
        client.download(args.remote_path, args.local_path)
    elif args.cmd == "info":
        client.info(args.path)


# ---------------------------
# icommand-compatible wrappers
# Output matches real icommands format
# ---------------------------
def _ensure_client():
    """Create client and ensure token is valid."""
    client = LCSBICommandsClient()
    if not client._ensure_token():
        sys.exit(1)
    return client


def cmd_icai():
    """Launch the interactive lcsb-icommands shell."""
    _setup_path()
    shell = LCSBICommandsShell()
    shell.run()


def cmd_iinit():
    """iinit replacement — authenticate via Keycloak."""
    _setup_path()
    parser = argparse.ArgumentParser(
        prog="iinit",
        description="Initialize iRODS session via Keycloak authentication",
        epilog=(
            "Auto-detects headless environments (SSH, HPC, no display). "
            "Use --headless to force device flow on any machine."
        ),
    )
    parser.add_argument("--token", "-t", help="Login with copied token (JSON or raw)")
    parser.add_argument(
        "--headless",
        action="store_true",
        default=False,
        help="Use device flow (print URL + code) instead of opening a browser. "
             "Auto-enabled on SSH/HPC sessions.",
    )
    args = parser.parse_args()

    client = LCSBICommandsClient()
    client.login(token_str=args.token, headless=args.headless)


def cmd_ils():
    """ils replacement — list collections and data objects."""
    parser = argparse.ArgumentParser(prog="ils", description="List iRODS collections and data objects")
    parser.add_argument("path", nargs="?", default="", help="iRODS path (e.g. /LCSB/projects/My_project)")
    args = parser.parse_args()

    client = _ensure_client()

    # Strip zone/projects prefix if user provides full iRODS path
    path = args.path.strip("/")
    for prefix in [f"LCSB/projects/", f"LCSB/projects"]:
        if path.startswith(prefix):
            path = path[len(prefix):]
            break

    try:
        data = client._api_get("ls", {"path": path})
        items = data.get("items", [])

        # Print in icommands format
        if path:
            print(f"/LCSB/projects/{path}:")
        else:
            print("/LCSB/projects:")

        for item in items:
            name = item["name"]
            if item["type"] == "collection":
                if path:
                    print(f"  C- /LCSB/projects/{path}/{name}")
                else:
                    print(f"  C- /LCSB/projects/{name}")
            else:
                print(f"  {name}")

    except requests.exceptions.HTTPError as e:
        if e.response and e.response.status_code == 404:
            print(f"ERROR: lsUtil: srcPath /LCSB/projects/{path} does not exist", file=sys.stderr)
            sys.exit(4)
        else:
            print(f"ERROR: {e}", file=sys.stderr)
            sys.exit(1)
    except Exception as e:
        print(f"ERROR: {e}", file=sys.stderr)
        sys.exit(1)


def cmd_iput():
    """iput replacement — upload files to iRODS."""
    parser = argparse.ArgumentParser(prog="iput", description="Upload files to iRODS")
    parser.add_argument("local_path", help="Local file or directory to upload")
    parser.add_argument("remote_path", help="Remote iRODS path (e.g. /LCSB/projects/My_project/dataset/)")
    parser.add_argument("-r", "--recursive", action="store_true", help="Upload directory recursively")
    parser.add_argument("-f", "--force", action="store_true", help="Force overwrite")
    args = parser.parse_args()

    client = _ensure_client()

    # Strip zone/projects prefix
    remote = args.remote_path.strip("/")
    for prefix in [f"LCSB/projects/", f"LCSB/projects"]:
        if remote.startswith(prefix):
            remote = remote[len(prefix):]
            break

    # Validate at least project/dataset
    parts = [p for p in remote.split("/") if p]
    if len(parts) < 2:
        print("ERROR: destination must be inside a project/dataset", file=sys.stderr)
        print("  e.g. iput file.txt /LCSB/projects/My_project/dataset_01/", file=sys.stderr)
        sys.exit(1)

    local = Path(args.local_path).resolve()
    if not local.exists():
        print(f"ERROR: {args.local_path} does not exist", file=sys.stderr)
        sys.exit(1)

    if local.is_dir() and not args.recursive:
        print(f"ERROR: {args.local_path} is a directory. Use -r flag.", file=sys.stderr)
        sys.exit(1)

    client.upload(remote, str(local))


def cmd_iget():
    """iget replacement — download files from iRODS."""
    parser = argparse.ArgumentParser(prog="iget", description="Download files from iRODS")
    parser.add_argument("remote_path", help="Remote iRODS path")
    parser.add_argument("local_path", nargs="?", default=".", help="Local destination (default: current dir)")
    parser.add_argument("-r", "--recursive", action="store_true", help="Download directory recursively")
    args = parser.parse_args()

    client = _ensure_client()

    # Strip zone/projects prefix
    remote = args.remote_path.strip("/")
    for prefix in [f"LCSB/projects/", f"LCSB/projects"]:
        if remote.startswith(prefix):
            remote = remote[len(prefix):]
            break

    client.download(remote, args.local_path)


def cmd_imkdir():
    """imkdir replacement — create collections."""
    parser = argparse.ArgumentParser(prog="imkdir", description="Create iRODS collection")
    parser.add_argument("path", help="iRODS collection path to create")
    parser.add_argument("-p", "--parents", action="store_true", help="Create parent collections as needed")
    args = parser.parse_args()

    client = _ensure_client()

    # Strip zone/projects prefix
    path = args.path.strip("/")
    for prefix in [f"LCSB/projects/", f"LCSB/projects"]:
        if path.startswith(prefix):
            path = path[len(prefix):]
            break

    try:
        r = requests.post(
            f"{LCSB_ICOMMANDS_SERVER}/mkdir",
            headers=client._auth_headers(),
            json={"path": path, "parents": args.parents},
            timeout=30,
        )
        if r.status_code == 401:
            print("ERROR: authentication failed. Run: iinit", file=sys.stderr)
            sys.exit(1)
        r.raise_for_status()
    except Exception as e:
        print(f"ERROR: {e}", file=sys.stderr)
        sys.exit(1)


def cmd_irm():
    """irm replacement — remove data objects."""
    parser = argparse.ArgumentParser(prog="irm", description="Remove iRODS data objects")
    parser.add_argument("path", help="iRODS path to remove")
    parser.add_argument("-r", "--recursive", action="store_true", help="Remove collection recursively")
    parser.add_argument("-f", "--force", action="store_true", help="Force removal")
    args = parser.parse_args()

    client = _ensure_client()

    # Strip zone/projects prefix
    path = args.path.strip("/")
    for prefix in [f"LCSB/projects/", f"LCSB/projects"]:
        if path.startswith(prefix):
            path = path[len(prefix):]
            break

    try:
        r = requests.post(
            f"{LCSB_ICOMMANDS_SERVER}/rm",
            headers=client._auth_headers(),
            json={"path": path, "recursive": args.recursive, "force": args.force},
            timeout=30,
        )
        if r.status_code == 401:
            print("ERROR: authentication failed. Run: iinit", file=sys.stderr)
            sys.exit(1)
        r.raise_for_status()
    except Exception as e:
        print(f"ERROR: {e}", file=sys.stderr)
        sys.exit(1)


def cmd_ipwd():
    """ipwd replacement — print current working collection."""
    print("/LCSB/projects")


def cmd_imeta():
    """imeta replacement — show metadata."""
    parser = argparse.ArgumentParser(prog="imeta", description="Show iRODS metadata")
    parser.add_argument("subcmd", choices=["ls"], help="Subcommand")
    parser.add_argument("-d", "--data-object", dest="path", help="Data object path")
    parser.add_argument("-C", "--collection", dest="coll_path", help="Collection path")
    args = parser.parse_args()

    client = _ensure_client()

    path = (args.path or args.coll_path or "").strip("/")
    for prefix in [f"LCSB/projects/", f"LCSB/projects"]:
        if path.startswith(prefix):
            path = path[len(prefix):]
            break

    if args.subcmd == "ls":
        client.info(path)


def cmd_icd():
    """icd replacement — change current working collection."""
    parser = argparse.ArgumentParser(prog="icd", description="Change current working collection")
    parser.add_argument("path", nargs="?", default="", help="Collection path")
    args = parser.parse_args()

    state_file = CREDENTIALS_FILE.parent / "cwd.json"

    if not args.path:
        # Print current working collection
        cwd = "/LCSB/projects"
        if state_file.exists():
            try:
                cwd = json.loads(state_file.read_text()).get("cwd", cwd)
            except Exception:
                pass
        print(cwd)
        return

    path = args.path.strip("/")
    for prefix in ["LCSB/projects/", "LCSB/projects"]:
        if path.startswith(prefix):
            path = path[len(prefix):]
            break

    # Validate path exists
    client = _ensure_client()
    try:
        client._api_get("ls", {"path": path})
    except Exception:
        print(f"ERROR: {args.path} does not exist or is not a collection", file=sys.stderr)
        sys.exit(4)

    full_path = f"/LCSB/projects/{path}" if path else "/LCSB/projects"
    state_file.parent.mkdir(exist_ok=True)
    state_file.write_text(json.dumps({"cwd": full_path}))


def cmd_imv():
    """imv replacement — move/rename data objects or collections."""
    parser = argparse.ArgumentParser(prog="imv", description="Move/rename iRODS data objects or collections")
    parser.add_argument("src", help="Source path")
    parser.add_argument("dst", help="Destination path")
    args = parser.parse_args()

    client = _ensure_client()

    src = args.src.strip("/")
    dst = args.dst.strip("/")
    for prefix in ["LCSB/projects/", "LCSB/projects"]:
        if src.startswith(prefix):
            src = src[len(prefix):]
        if dst.startswith(prefix):
            dst = dst[len(prefix):]

    try:
        r = requests.post(
            f"{LCSB_ICOMMANDS_SERVER}/mv",
            headers=client._auth_headers(),
            json={"src": src, "dst": dst},
            timeout=30,
        )
        if r.status_code == 401:
            print("ERROR: authentication failed. Run: iinit", file=sys.stderr)
            sys.exit(1)
        r.raise_for_status()
    except requests.exceptions.HTTPError as e:
        detail = ""
        if e.response:
            try:
                detail = e.response.json().get("detail", "")
            except Exception:
                detail = e.response.text
        print(f"ERROR: {detail or e}", file=sys.stderr)
        sys.exit(1)
    except Exception as e:
        print(f"ERROR: {e}", file=sys.stderr)
        sys.exit(1)


def cmd_icp():
    """icp replacement — copy data objects."""
    parser = argparse.ArgumentParser(prog="icp", description="Copy iRODS data objects")
    parser.add_argument("src", help="Source path")
    parser.add_argument("dst", help="Destination path")
    args = parser.parse_args()

    client = _ensure_client()

    src = args.src.strip("/")
    dst = args.dst.strip("/")
    for prefix in ["LCSB/projects/", "LCSB/projects"]:
        if src.startswith(prefix):
            src = src[len(prefix):]
        if dst.startswith(prefix):
            dst = dst[len(prefix):]

    try:
        r = requests.post(
            f"{LCSB_ICOMMANDS_SERVER}/cp",
            headers=client._auth_headers(),
            json={"src": src, "dst": dst},
            timeout=30,
        )
        if r.status_code == 401:
            print("ERROR: authentication failed. Run: iinit", file=sys.stderr)
            sys.exit(1)
        r.raise_for_status()
    except requests.exceptions.HTTPError as e:
        detail = ""
        if e.response:
            try:
                detail = e.response.json().get("detail", "")
            except Exception:
                detail = e.response.text
        print(f"ERROR: {detail or e}", file=sys.stderr)
        sys.exit(1)
    except Exception as e:
        print(f"ERROR: {e}", file=sys.stderr)
        sys.exit(1)


def cmd_ichmod():
    """ichmod replacement — change permissions."""
    parser = argparse.ArgumentParser(prog="ichmod", description="Change iRODS permissions")
    parser.add_argument("permission", choices=["read", "write", "own", "null"], help="Permission level (null = remove)")
    parser.add_argument("user", help="Username to grant permission to")
    parser.add_argument("path", help="iRODS path")
    parser.add_argument("-r", "--recursive", action="store_true", help="Apply recursively")
    args = parser.parse_args()

    client = _ensure_client()

    path = args.path.strip("/")
    for prefix in ["LCSB/projects/", "LCSB/projects"]:
        if path.startswith(prefix):
            path = path[len(prefix):]
            break

    try:
        r = requests.post(
            f"{LCSB_ICOMMANDS_SERVER}/chmod",
            headers=client._auth_headers(),
            json={"path": path, "user": args.user, "permission": args.permission},
            timeout=30,
        )
        if r.status_code == 401:
            print("ERROR: authentication failed. Run: iinit", file=sys.stderr)
            sys.exit(1)
        r.raise_for_status()
    except requests.exceptions.HTTPError as e:
        detail = ""
        if e.response:
            try:
                detail = e.response.json().get("detail", "")
            except Exception:
                detail = e.response.text
        print(f"ERROR: {detail or e}", file=sys.stderr)
        sys.exit(1)
    except Exception as e:
        print(f"ERROR: {e}", file=sys.stderr)
        sys.exit(1)


def cmd_ichksum():
    """ichksum replacement — checksum data objects."""
    parser = argparse.ArgumentParser(prog="ichksum", description="Checksum iRODS data objects")
    parser.add_argument("path", help="iRODS data object path")
    args = parser.parse_args()

    client = _ensure_client()

    path = args.path.strip("/")
    for prefix in ["LCSB/projects/", "LCSB/projects"]:
        if path.startswith(prefix):
            path = path[len(prefix):]
            break

    try:
        data = client._api_get("checksum", {"path": path})
        chksum = data.get("checksum", "(none)")
        irods_path = data.get("irods_path", path)
        print(f"    {irods_path}    {chksum}")
    except requests.exceptions.HTTPError as e:
        if e.response and e.response.status_code == 404:
            print(f"ERROR: {args.path} does not exist", file=sys.stderr)
            sys.exit(4)
        else:
            print(f"ERROR: {e}", file=sys.stderr)
            sys.exit(1)
    except Exception as e:
        print(f"ERROR: {e}", file=sys.stderr)
        sys.exit(1)


def cmd_itree():
    """itree replacement — display collection tree."""
    parser = argparse.ArgumentParser(prog="itree", description="Display iRODS collection tree")
    parser.add_argument("path", nargs="?", default="", help="Root path")
    args = parser.parse_args()

    client = _ensure_client()

    path = args.path.strip("/")
    for prefix in ["LCSB/projects/", "LCSB/projects"]:
        if path.startswith(prefix):
            path = path[len(prefix):]
            break

    try:
        data = client._api_get("tree", {"path": path})
        items = data.get("items", [])

        root_display = f"/LCSB/projects/{path}" if path else "/LCSB/projects"
        print(root_display)

        for i, item in enumerate(items):
            depth = item.get("depth", 0)
            name = item["name"]
            is_last = (i + 1 >= len(items)) or (items[i + 1].get("depth", 0) <= depth)

            indent = ""
            for d in range(depth):
                indent += "│   "

            connector = "└── " if is_last else "├── "

            if item["type"] == "collection":
                print(f"{indent}{connector}{name}/")
            else:
                print(f"{indent}{connector}{name}")

    except Exception as e:
        print(f"ERROR: {e}", file=sys.stderr)
        sys.exit(1)


def cmd_ienv():
    """ienv replacement — display lcsb-icommands environment."""
    client = LCSBICommandsClient()
    print("lcsb-icommands Environment:")
    print(f"  lcsb-icommands Server:       {LCSB_ICOMMANDS_SERVER}")
    print(f"  Keycloak URL:      {KEYCLOAK_URL}")
    print(f"  Client ID:         {CLIENT_ID}")
    print(f"  Credentials file:  {CREDENTIALS_FILE}")
    print(f"  Username:          {client.username or '(not logged in)'}")
    if client._token_valid():
        remaining = int(client.expires_at - time.time())
        mins, secs = divmod(remaining, 60)
        print(f"  Token:             valid ({mins}m {secs}s remaining)")
    elif client.token:
        print(f"  Token:             expired")
        if client.refresh_token:
            print(f"  Refresh:           available")
    else:
        print(f"  Token:             none")
    print(f"  iRODS Zone:        LCSB")
    print(f"  Projects root:     /LCSB/projects")


def cmd_ihelp():
    """ihelp replacement — list available commands."""
    _setup_path()
    print("lcsb-icommands iCommands (iRODS-compatible) and a brief description:")
    print()
    cmds = [
        ("ichksum",  "checksum one or more Data Objects."),
        ("ichmod",   "change access permissions to Collections or Data Objects."),
        ("icd",      "change the current working directory (Collection)."),
        ("icp",      "copy a Data Object to another location."),
        ("ienv",     "display current lcsb-icommands environment."),
        ("iget",     "get a file from iRODS."),
        ("ihelp",    "display this help message."),
        ("iinit",    "initialize a session via Keycloak authentication."),
        ("ils",      "list Collections and Data Objects."),
        ("imeta",    "list user-defined metadata."),
        ("imkdir",   "make an iRODS directory (Collection)."),
        ("imv",      "move/rename a Data Object or Collection."),
        ("ipwd",     "print the current working directory (Collection)."),
        ("iput",     "put (store) a file into iRODS."),
        ("irm",      "remove one or more Data Objects or Collections."),
        ("itree",    "display a collection structure as a tree."),
    ]
    for name, desc in cmds:
        print(f"  {name:<12} - {desc}")
    print()
    print("For more information: '<command> --help'")
    print("lcsb-icommands interactive shell: 'icai'")
    print(f"lcsb-icommands Version 1.1.2                ihelp")


if __name__ == "__main__":
    main()