# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""Run the `ibmi` CLI *inside* a running Ixora managed-system container.

A thin wrapper around the bundled `ibmi` binary in the stack's `api-<id>` container —
nothing more. Agent *definition* (create / update / delete) is the `ixora agents`
CLI's job now; this script exists for the one thing that CLI can't do: introspect
the IBM i schema with the EXACT credentials the agent will use at run time.

Why the container `ibmi` and not the host one: the `api-<id>` container's bundled
`ibmi` connects through its `IBMI_*` env — the creds the ixora stack is deployed
with — so what you introspect is what the agent queries. The host `ibmi` has its own
`~/.ibmi` registry, independent of ixora, that may point at a different box.

Resolution of which managed system / container to exec into mirrors
ixora-cli/src/lib/agentos-resolver.ts and the `~/.ixora` layout in
ixora-cli/src/lib/constants.ts — so it targets the same system your `ixora` commands do.

Subcommands (stdlib only — no third-party deps):
  resolve   Print the resolved system id, kind, endpoint, and the `ibmi` invocation.
  ibmi      Exec the container's `ibmi` CLI; output passes through verbatim.

Run with `uv run container_ibmi.py <subcommand> ...`, or plain `python3 container_ibmi.py`.
`resolve` prints a JSON envelope and exits 0/1; `ibmi` forwards the container's own
stdout/stderr and exit code.
"""

from __future__ import annotations

import argparse
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any

# ~/.ixora layout (mirrors ixora-cli/src/lib/constants.ts)
_DEFAULT_API_PORT = 18000


# ---------------------------------------------------------------------------
# Small result/exception helpers
# ---------------------------------------------------------------------------


class BuilderError(Exception):
    """A user-facing failure; its message goes into the JSON error envelope."""


def _emit(payload: dict[str, Any]) -> "NoReturnExit":
    """Print a JSON envelope and exit (0 if ok, 1 otherwise)."""
    print(json.dumps(payload, indent=2))
    raise SystemExit(0 if payload.get("ok") else 1)


# Type alias only used so _emit's intent reads clearly.
NoReturnExit = SystemExit


# ---------------------------------------------------------------------------
# ~/.ixora state readers — mirror ixora-cli/src/lib/{env,systems,constants}.ts
# ---------------------------------------------------------------------------


def _ixora_dir(args: argparse.Namespace) -> Path:
    override = getattr(args, "ixora_dir", None)
    if override:
        return Path(override).expanduser()
    return Path(os.environ.get("IXORA_DIR") or (Path.home() / ".ixora"))


def _env_get(ixora_dir: Path, key: str) -> str:
    """Read KEY from ~/.ixora/.env, stripping matched surrounding quotes."""
    env_file = ixora_dir / ".env"
    if not env_file.is_file():
        return ""
    for line in env_file.read_text().splitlines():
        if line.startswith(f"{key}="):
            val = line[len(key) + 1 :]
            if (val.startswith("'") and val.endswith("'")) or (
                val.startswith('"') and val.endswith('"')
            ):
                val = val[1:-1]
            return val
    return ""


def _api_port_base(ixora_dir: Path) -> int:
    raw = _env_get(ixora_dir, "IXORA_API_PORT")
    if not raw:
        return _DEFAULT_API_PORT
    try:
        n = int(raw)
    except ValueError:
        sys.stderr.write(
            f"WARNING: IXORA_API_PORT={raw!r} is not an integer; "
            f"using {_DEFAULT_API_PORT}.\n"
        )
        return _DEFAULT_API_PORT
    if 1024 <= n <= 65535:
        return n
    sys.stderr.write(
        f"WARNING: IXORA_API_PORT={n} out of range (1024-65535); "
        f"using {_DEFAULT_API_PORT}.\n"
    )
    return _DEFAULT_API_PORT


def _read_systems(ixora_dir: Path) -> list[dict[str, str]]:
    """Parse ~/.ixora/ixora-systems.yaml the same line-oriented way the CLI does.

    Intentionally mirrors ixora-cli/src/lib/systems.ts, which also *writes* this file
    with fixed 2/4-space indent and single-quote escaping. Do NOT "fix" this into
    yaml.safe_load — yaml accepts double-quoted / flow / anchor forms the TS reader
    rejects, which would diverge resolution from the file's own writer. Hand-edits
    must use exactly that 2/4-space-indent, single-quoted format.
    """
    cfg = ixora_dir / "ixora-systems.yaml"
    if not cfg.is_file():
        return []
    systems: list[dict[str, str]] = []
    current: dict[str, str] | None = None

    def commit() -> None:
        if not current or not current.get("id"):
            return
        kind = current.get("kind", "managed")
        if kind == "external":
            if not current.get("url"):
                return  # skip malformed external entries
            systems.append(
                {
                    "id": current["id"],
                    "name": current.get("name", current["id"]),
                    "kind": "external",
                    "url": current["url"],
                }
            )
        else:
            systems.append(
                {
                    "id": current["id"],
                    "name": current.get("name", current["id"]),
                    "kind": "managed",
                    "mode": current.get("mode", "full"),
                }
            )

    for line in cfg.read_text().splitlines():
        m = re.match(r"^ {2}- id: (.+)$", line)
        if m:
            commit()
            current = {"id": m.group(1).strip()}
            continue
        if current is None:
            continue
        for field, pat in (
            ("name", r"^ {4}name: *'?([^']*)'?"),
            ("kind", r"^ {4}kind: *'?(managed|external)'?"),
            ("mode", r"^ {4}mode: *'?([^']*)'?"),
            ("url", r"^ {4}url: *'?([^']*)'?"),
        ):
            fm = re.match(pat, line)
            if fm:
                current[field] = fm.group(1).strip()
                break
    commit()
    return systems


def _index_among_managed(systems: list[dict[str, str]], target_id: str) -> int:
    managed = [s for s in systems if s.get("kind", "managed") == "managed"]
    for i, s in enumerate(managed):
        if s["id"] == target_id:
            return i
    return -1


class Target:
    """The managed system this invocation resolves to (+ informative endpoint)."""

    def __init__(
        self,
        base_url: str,
        system_id: str | None,
        kind: str,
        user_tools_dir: Path | None,
    ) -> None:
        self.base_url = base_url.rstrip("/")
        self.system_id = system_id
        self.kind = kind  # "managed" | "external" | "url"
        self.user_tools_dir = user_tools_dir

    def describe(self) -> dict[str, Any]:
        info: dict[str, Any] = {
            "base_url": self.base_url,
            "system_id": self.system_id,
            "kind": self.kind,
            "user_tools_dir": str(self.user_tools_dir) if self.user_tools_dir else None,
        }
        # Managed systems carry a local api-<id> container whose bundled `ibmi`
        # binary targets the ixora-configured IBM i box (IBMI_* env). Introspect
        # through it via this script's `ibmi` subcommand — never the host `ibmi`.
        if self.kind == "managed" and self.system_id:
            info["ibmi_via"] = f"container_ibmi.py ibmi --system {self.system_id} -- ..."
        else:
            info["ibmi_via"] = None  # external/url systems have no local container
        return info


def _resolve_target(args: argparse.Namespace) -> Target:
    """Mirror ixora-cli/src/lib/agentos-resolver.ts (minus the docker-running check).

    Precedence: --url > --system > single configured system > IXORA_DEFAULT_SYSTEM.
    We deliberately do NOT shell out to `docker compose ps`; if the picked managed
    system isn't running, `ibmi` surfaces a clean exec error instead.
    """
    ixora_dir = _ixora_dir(args)
    user_tools_override = getattr(args, "user_tools_dir", None)

    # 1. --url escape hatch (no local container — useful only to confirm a target).
    if getattr(args, "url", None):
        utd = Path(user_tools_override).expanduser() if user_tools_override else None
        return Target(args.url, None, "url", utd)

    systems = _read_systems(ixora_dir)
    if not systems:
        raise BuilderError(
            "No systems configured in ~/.ixora/ixora-systems.yaml. Run "
            "`ixora stack install` or `ixora stack system add`, or pass --url."
        )

    # 2. Pick a target.
    sys_flag = getattr(args, "system", None)
    if sys_flag:
        target = next((s for s in systems if s["id"] == sys_flag), None)
        if target is None:
            ids = ", ".join(s["id"] for s in systems)
            raise BuilderError(f"No such system '{sys_flag}'. Configured: {ids}")
    elif len(systems) == 1:
        target = systems[0]
    else:
        default_id = _env_get(ixora_dir, "IXORA_DEFAULT_SYSTEM")
        target = next((s for s in systems if s["id"] == default_id), None)
        if target is None:
            ids = ", ".join(s["id"] for s in systems)
            raise BuilderError(
                f"Multiple systems configured — specify --system <id>. Available: {ids}"
            )

    # 3. base URL (informative — shows which AgentOS the system maps to).
    if target["kind"] == "external":
        base_url = target["url"]
    else:
        idx = _index_among_managed(systems, target["id"])
        base_url = f"http://localhost:{_api_port_base(ixora_dir) + max(idx, 0)}"

    # 4. user_tools dir. Managed bind-mounts ~/.ixora/user_tools -> /data/user_tools
    #    (where `ixora agents create` writes a custom-tool agent's tools). External
    #    systems read user_tools from a host/container we can't see → None.
    if user_tools_override:
        user_tools_dir: Path | None = Path(user_tools_override).expanduser()
    elif target["kind"] == "managed":
        user_tools_dir = ixora_dir / "user_tools"
    else:
        user_tools_dir = None

    return Target(base_url, target["id"], target["kind"], user_tools_dir)


# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------


def cmd_resolve(args: argparse.Namespace) -> None:
    target = _resolve_target(args)
    _emit({"ok": True, **target.describe()})


def _detect_compose_cmd(override: str | None) -> list[str]:
    """Pick a compose command: --compose-cmd override, else docker/podman/legacy."""
    if override:
        return override.split()
    if shutil.which("docker"):
        return ["docker", "compose"]
    if shutil.which("podman"):
        return ["podman", "compose"]
    if shutil.which("docker-compose"):
        return ["docker-compose"]
    raise BuilderError(
        "No docker/podman compose command on PATH (needed to exec the container's "
        "ibmi). Pass --compose-cmd, or introspect another way."
    )


def cmd_ibmi(args: argparse.Namespace) -> None:
    """Run the container's `ibmi` CLI against the resolved managed system.

    Execs `<compose> -f ~/.ixora/docker-compose.yml exec -T api-<id> ibmi <args>`,
    so introspection uses the IBM i creds the ixora stack is deployed with (the api
    container's IBMI_* env) — the EXACT system the agent queries at run time. This
    is why you never call the host `ibmi`: its ~/.ibmi registry is independent of
    ixora and may point at a different box. Output passes through verbatim (not a
    JSON envelope); add `--raw` to the ibmi args for JSON.
    """
    target = _resolve_target(args)
    if target.kind != "managed" or not target.system_id:
        _emit(
            {
                "ok": False,
                "error": (
                    f"container ibmi needs a managed system (resolved kind="
                    f"{target.kind!r}); external/url systems have no local container. "
                    "Introspect via that AgentOS's own agents/tools, or build a "
                    "toolset-only agent."
                ),
            }
        )
    ixora_dir = _ixora_dir(args)
    compose_file = ixora_dir / "docker-compose.yml"
    if not compose_file.is_file():
        _emit({"ok": False, "error": f"compose file not found: {compose_file}"})
    ibmi_args = list(args.ibmi_args or [])
    if ibmi_args and ibmi_args[0] == "--":
        ibmi_args = ibmi_args[1:]
    if not ibmi_args:
        _emit(
            {
                "ok": False,
                "error": "no ibmi args given — e.g. `ibmi --system <id> -- schemas`.",
            }
        )
    # argparse REMAINDER swallows everything after the first positional, so a target
    # flag placed AFTER the `--` lands here and would silently mis-target resolution.
    # Fail loudly instead — target flags must precede the `--`.
    _target_flags = {
        "--system",
        "--url",
        "--user-tools-dir",
        "--ixora-dir",
        "--compose-cmd",
    }
    leaked = [a for a in ibmi_args if a in _target_flags]
    if leaked:
        _emit(
            {
                "ok": False,
                "error": (
                    f"target flag(s) {leaked} appear after the '--' and would be passed "
                    "to the container ibmi (silently mis-targeting resolution). Put "
                    "target flags BEFORE the '--': `ibmi --system <id> -- <ibmi args>`."
                ),
            }
        )
    compose_cmd = _detect_compose_cmd(getattr(args, "compose_cmd", None))
    cmd = [
        *compose_cmd,
        "-f",
        str(compose_file),
        "exec",
        "-T",
        f"api-{target.system_id}",
        "ibmi",
        *ibmi_args,
    ]
    try:
        proc = subprocess.run(cmd)  # inherit stdio — ibmi prints its own output
    except FileNotFoundError as exc:
        _emit({"ok": False, "error": f"failed to run {compose_cmd[0]!r}: {exc}"})
        return  # unreachable: _emit raises — present so the type checker sees it
    if proc.returncode != 0:
        # Self-describe the layout assumptions (service name `api-<id>` + compose path
        # track ixora-cli/src/lib/constants.ts) so a mismatch isn't an opaque error.
        sys.stderr.write(
            f"(container ibmi exited {proc.returncode}; ran in service "
            f"'api-{target.system_id}' via {compose_file}. If that service is missing, "
            "check `ixora stack status`.)\n"
        )
    raise SystemExit(proc.returncode)


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------


def _add_target_flags(p: argparse.ArgumentParser) -> None:
    p.add_argument("--system", help="Target a configured system by id")
    p.add_argument("--url", help="Target this AgentOS URL directly (escape hatch)")
    p.add_argument(
        "--user-tools-dir",
        help="Override the host user_tools dir (informative on `resolve`)",
    )
    p.add_argument("--ixora-dir", help="Override ~/.ixora (testing)")


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="container_ibmi.py",
        description="Run the container's `ibmi` CLI against a running Ixora managed system.",
    )
    sub = parser.add_subparsers(dest="command", required=True)

    p = sub.add_parser(
        "resolve", help="Print the resolved system, endpoint, and `ibmi` invocation"
    )
    _add_target_flags(p)
    p.set_defaults(func=cmd_resolve)

    p = sub.add_parser(
        "ibmi",
        help="Run the container's ibmi CLI against the ixora-configured system",
    )
    p.add_argument(
        "--compose-cmd", help="Override compose command (e.g. 'podman compose')"
    )
    _add_target_flags(p)
    p.add_argument(
        "ibmi_args",
        nargs=argparse.REMAINDER,
        help="ibmi args after `--` (e.g. -- schemas | -- tables QSYS2 | -- validate '<sql>')",
    )
    p.set_defaults(func=cmd_ibmi)

    return parser


def main(argv: list[str] | None = None) -> None:
    args = build_parser().parse_args(argv)
    try:
        args.func(args)
    except BuilderError as exc:
        _emit({"ok": False, "error": str(exc)})


if __name__ == "__main__":
    main()
