use clap::Parser;
use concepts::{
    ComponentType, ExecutionId, FunctionFqn, FunctionFqnParseError,
    prefixed_ulid::{DelayId, DeploymentId, ExecutionIdDerived},
};
use secrecy::SecretString;

/// Deployment TOML section names, used as the key in the deployment TOML file.
#[derive(
    Debug, Clone, Copy, strum::Display, strum::EnumString, serde::Serialize, serde::Deserialize,
)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub(crate) enum TomlComponentType {
    ActivityWasm,
    ActivityStub,
    ActivityExternal,
    ActivityJs,
    ActivityExec,
    WorkflowWasm,
    WorkflowJs,
    WebhookEndpointWasm,
    WebhookEndpointJs,
    Cron,
}
use std::{path::PathBuf, str::FromStr};

fn parse_oci_reference(s: &str) -> Result<oci_client::Reference, String> {
    let s = s
        .strip_prefix("oci://")
        .ok_or_else(|| format!("OCI reference must start with `oci://`, got: {s}"))?;
    oci_client::Reference::from_str(s).map_err(|e| e.to_string())
}

#[expect(
    clippy::unnecessary_wraps,
    reason = "clap function value parsers must return Result"
)]
fn parse_secret_string(value: &str) -> Result<SecretString, std::convert::Infallible> {
    Ok(SecretString::from(value.to_owned()))
}

/// A deployment source: either a path to a TOML file or an existing deployment ID.
#[derive(Debug, Clone)]
pub(crate) enum DeploymentSource {
    File(PathBuf),
    Id(DeploymentId),
}

impl FromStr for DeploymentSource {
    type Err = std::convert::Infallible;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        if let Ok(id) = s.parse::<DeploymentId>() {
            Ok(DeploymentSource::Id(id))
        } else {
            Ok(DeploymentSource::File(PathBuf::from(s)))
        }
    }
}

pub(crate) mod shadow {
    pub(crate) const PKG_VERSION: &str = env!("PKG_VERSION");
}

#[derive(Parser, Debug)]
#[clap(name = "obelisk")]
#[command
(
    version = const_format::formatcp!("{}", shadow::PKG_VERSION),
about = "Obelisk: deterministic workflow engine", disable_version_flag = true, disable_help_subcommand = true)]
pub(crate) struct Args {
    #[command(subcommand)]
    pub(crate) command: Subcommand,

    /// Print version
    #[arg(short, long, action = clap::ArgAction::Version)]
    version: Option<bool>,
}

/// Flattened only into client subcommand groups: the token is meaningless for
/// `server` and `generate`, so it does not live on the top-level `Args`.
#[derive(Debug, clap::Args)]
pub(crate) struct ClientToken {
    /// API token presented to the server as `Authorization: Bearer <token>`.
    /// Falls back to `$OBELISK_API_TOKEN`, then `$OBELISK__API__TOKEN`.
    #[arg(
        long,
        global = true,
        value_name = "TOKEN",
        value_parser = parse_secret_string
    )]
    pub(crate) api_token: Option<SecretString>,
}

#[derive(Debug, clap::Subcommand)]
pub(crate) enum Subcommand {
    /// Run or verify the Obelisk server.
    #[command(subcommand)]
    Server(Server),
    /// Submit, inspect, stub, cancel, pause, replay, persist backtraces, or upgrade executions.
    Execution(ExecutionArgs),
    /// Inspect components or add/push them to an OCI registry.
    Component(ComponentArgs),
    /// Manage deployments.
    Deployment(DeploymentArgs),
    /// Generate configuration files and WIT artifacts.
    #[command(subcommand)]
    Generate(Generate),
}

#[derive(Debug, clap::Args)]
pub(crate) struct ExecutionArgs {
    #[command(subcommand)]
    pub(crate) command: Execution,
    #[command(flatten)]
    pub(crate) token: ClientToken,
}

#[derive(Debug, clap::Args)]
pub(crate) struct ComponentArgs {
    #[command(subcommand)]
    pub(crate) command: Component,
    #[command(flatten)]
    pub(crate) token: ClientToken,
}

#[derive(Debug, clap::Args)]
pub(crate) struct DeploymentArgs {
    #[command(subcommand)]
    pub(crate) command: Deployment,
    #[command(flatten)]
    pub(crate) token: ClientToken,
}

#[derive(Debug, clap::Subcommand)]
pub(crate) enum Deployment {
    /// Upload a deployment TOML as a new deployment; print the new deployment ID.
    Submit {
        /// Path to the deployment TOML file.
        #[arg(
            value_name = "PATH",
            required_unless_present = "empty",
            conflicts_with = "empty"
        )]
        file: Option<PathBuf>,
        /// Submit an empty deployment with no components.
        #[arg(long)]
        empty: bool,
        /// Tolerate runtime requirements unavailable on this server while verifying
        /// the deployment before persisting it.
        #[arg(long)]
        allow_unavailable_runtime_config: bool,
        /// Optional human-readable description.
        #[arg(long)]
        description: Option<String>,
        /// Optional client-supplied deployment ID for idempotent submission.
        ///
        /// If a deployment with this ID already exists and its content digest
        /// matches, the submission is a no-op; a digest mismatch is an error.
        /// Generate one with `obelisk generate deployment-id`.
        #[arg(long)]
        deployment_id: Option<DeploymentId>,
        /// Address of the obelisk server
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
    },
    /// Submit (if a file is given) and enqueue for next server restart.
    Enqueue {
        /// Path to a deployment TOML file, or an existing deployment ID.
        #[arg(
            value_name = "PATH|ID",
            required_unless_present = "empty",
            conflicts_with = "empty"
        )]
        source: Option<DeploymentSource>,
        /// Enqueue an empty deployment with no components.
        #[arg(long)]
        empty: bool,
        /// Tolerate runtime requirements unavailable on this server while verifying
        /// the deployment before enqueuing it.
        #[arg(long)]
        allow_unavailable_runtime_config: bool,
        /// Optional human-readable description for a newly submitted deployment.
        #[arg(long)]
        description: Option<String>,
        /// Optional client-supplied deployment ID for idempotent submission.
        ///
        /// Only valid when submitting a file/empty deployment, not when `source`
        /// is an existing deployment ID. Generate one with
        /// `obelisk generate deployment-id`.
        #[arg(long)]
        deployment_id: Option<DeploymentId>,
        /// Address of the obelisk server
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
    },
    /// Submit (if a file is given) and hot-redeploy immediately. Fails if not possible.
    Apply {
        /// Path to a deployment TOML file, or an existing deployment ID.
        #[arg(
            value_name = "PATH|ID",
            required_unless_present = "empty",
            conflicts_with = "empty"
        )]
        source: Option<DeploymentSource>,
        /// Apply an empty deployment with no components.
        #[arg(long)]
        empty: bool,
        /// Optional human-readable description for a newly submitted deployment.
        #[arg(long)]
        description: Option<String>,
        /// Optional client-supplied deployment ID for idempotent submission.
        ///
        /// Only valid when submitting a file/empty deployment, not when `source`
        /// is an existing deployment ID. Generate one with
        /// `obelisk generate deployment-id`.
        #[arg(long)]
        deployment_id: Option<DeploymentId>,
        /// Address of the obelisk server
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
    },
    /// List recent deployments.
    List {
        /// Address of the obelisk server
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
    },
    /// Delete content-addressed file blobs not referenced by any stored deployment.
    ///
    /// Such orphans are left behind when a submit writes blobs to the store and then
    /// fails verification before persisting the deployment.
    Gc {
        /// Address of the obelisk server
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
    },
    /// Print the ID of the currently active deployment.
    Active {
        /// Output as JSON instead of plain text.
        #[arg(long)]
        json: bool,
        /// Address of the obelisk server
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
    },

    /// Show a deployment.
    ///
    /// By default prints the reconstructed `deployment.toml` (with local file references,
    /// the same TOML `deployment get` writes to disk). Pass a FILE to print a single source
    /// file as `deployment get` would serialize it, or `--json` to render the stored TOML
    /// manifest as JSON.
    Show {
        /// Deployment ID
        #[arg(value_name = "ID")]
        id: DeploymentId,
        /// Print the content of a single source file, identified by its deployment-relative
        /// path as shown in the TOML (e.g. `scripts/run.sh`).
        #[arg(value_name = "FILE", conflicts_with = "json")]
        file: Option<String>,
        /// Print the stored TOML manifest as JSON instead of TOML.
        #[arg(long)]
        json: bool,
        /// Address of the obelisk server
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
    },
    /// Retrieve a deployment to disk as a re-submittable `deployment.toml` plus its source files.
    Get {
        /// Deployment ID
        #[arg(value_name = "ID")]
        id: DeploymentId,
        /// Directory to write `deployment.toml` and source files into. Defaults to the
        /// current directory.
        #[arg(long, short)]
        output: Option<PathBuf>,
        /// Overwrite existing files in the output directory.
        #[arg(long, short)]
        force: bool,
        /// Address of the obelisk server
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
    },
    /// Verify a local deployment without accessing the server database.
    Verify(DeploymentVerifyArgs),
}

#[derive(Debug, clap::Subcommand)]
pub(crate) enum Generate {
    /// Generate extension WIT files that are automatically implemented by Obelisk
    /// based on the exported interfaces of the component (e.g. `-schedule`, `-await-next` variants).
    WitExtensions {
        /// Output as JSON instead of human-readable text.
        #[arg(short, long)]
        json: bool,
        /// Overwrite existing files in the output directory.
        #[arg(long, short)]
        force: bool,
        /// Component type this WIT is for. One of `workflow`, `activity`, `activity_stub`, `webhook_endpoint`.
        component_type: ComponentType,
        /// Path to the `wit` folder containing the target world and, if present, a `deps` subfolder.
        input_wit_directory: PathBuf,
        /// Directory where folders and WIT files will be written to.
        output_directory: PathBuf,
    },
    /// Generate Obelisk's built-in support WIT files (host interfaces) for the given component type.
    WitSupport {
        /// Output as JSON instead of human-readable text.
        #[arg(short, long)]
        json: bool,
        /// Component type whose host interfaces to emit. One of `workflow`, `activity`, `activity_stub`, `webhook_endpoint`.
        component_type: ComponentType,
        /// Directory where folders and WIT files will be written to.
        output_directory: PathBuf,
        /// Overwrite existing files in the output directory.
        #[arg(long, short)]
        force: bool,
    },
    /// Generate WIT dependency folder based on activities and workflows found in the deployment TOML.
    WitDeps {
        /// Output as JSON instead of human-readable text.
        #[arg(short, long)]
        json: bool,
        /// Path to the deployment TOML file.
        #[arg(long, short)]
        deployment: PathBuf,
        /// Directory where folders and WIT files will be written to.
        output_directory: PathBuf,
        /// Overwrite existing files.
        #[arg(long, short)]
        force: bool,
        /// Skip local-path components.
        #[arg(long)]
        skip_local: bool,
    },
    /// Generate a default server.toml.
    ServerConfig {
        /// Output as JSON instead of human-readable text.
        #[arg(short, long)]
        json: bool,
        /// Generate a single-party config that allows all exec activities and outbound HTTP.
        #[arg(long)]
        trusted: bool,
        /// Filename to write the TOML to, defaults to <stdout>.
        output: Option<PathBuf>,
        /// Overwrite existing file.
        #[arg(long, short)]
        force: bool,
    },
    /// Generate a default deployment.toml.
    Deployment {
        /// Output as JSON instead of human-readable text.
        #[arg(short, long)]
        json: bool,
        /// Filename to write the TOML to, defaults to <stdout>.
        output: Option<PathBuf>,
        /// Overwrite existing file.
        #[arg(long, short)]
        force: bool,
    },
    /// Generate a fresh random execution ID and print it to stdout.
    ExecutionId {
        /// Output as JSON instead of plain text.
        #[arg(short, long)]
        json: bool,
    },
    /// Generate a fresh random deployment ID and print it to stdout.
    ///
    /// The ID can be passed to `deployment submit/enqueue/apply --deployment-id`
    /// for idempotent submission.
    DeploymentId {
        /// Output as JSON instead of plain text.
        #[arg(short, long)]
        json: bool,
    },
    /// Generate a random API token and print it to stdout.
    ///
    /// Stdout carries only the token, so it composes with env injection:
    /// `OBELISK__API__TOKEN=$(obelisk generate token)`. When run interactively,
    /// the token's `api.token_hashes` entry is also printed to stderr.
    /// The plaintext token is printed once and never stored; revoke it by
    /// deleting its `api.token_hashes` entry and restarting the server.
    Token {
        /// Output the token and its `sha256:` hash as JSON instead of plain text.
        #[arg(short, long)]
        json: bool,
        /// Append the token's hash to `api.token_hashes` in this server.toml file.
        #[arg(short, long)]
        server_config: Option<PathBuf>,
    },
    /// Print a prompt context for authoring an Obelisk application with a coding agent.
    ///
    /// Usage: obelisk generate prompt description of what to build | claude
    ///
    /// Example:
    ///   obelisk generate prompt Create a daily task that monitors GitHub org obeli-sk | claude
    #[command(trailing_var_arg = true)]
    Prompt {
        /// Description of the application to build.
        #[arg(trailing_var_arg = true, required = true)]
        description: Vec<String>,
    },
}

#[derive(Debug, clap::Subcommand)]
pub(crate) enum Server {
    /// Start the Obelisk server.
    Run {
        /// Delete the sqlite database directory before starting. Destroys all execution history.
        #[arg(long)]
        clean_sqlite_directory: bool,
        /// Delete both the codegen cache and the OCI image cache before starting.
        #[arg(long)]
        clean_cache: bool,
        /// Delete only the codegen cache before starting; OCI image cache is kept.
        #[arg(long)]
        clean_codegen_cache: bool,
        /// Path to the server configuration file (server.toml). If omitted, built-in defaults are used.
        #[arg(short, long)]
        server_config: Option<PathBuf>,
        /// Path to the deployment TOML file. If provided, the deployment is inserted and activated on startup,
        /// overriding any existing Enqueued or Active deployment in the database.
        #[arg(short, long, conflicts_with = "empty")]
        deployment: Option<PathBuf>,
        /// Start with an empty deployment, ignoring any Enqueued or Active deployment in the database.
        /// Useful for recovering from a faulty deployment: start empty, then push a new deployment or switch to existing via gRPC.
        #[arg(long)]
        empty: bool,
        /// Optional human-readable description for the deployment created by --deployment or --empty.
        #[arg(long)]
        description: Option<String>,
        /// Do not fail startup when a component's imports/exports fail type checking against the current deployment.
        #[arg(long)]
        suppress_type_checking_errors: bool,
        /// Accept unauthenticated requests on the API port. Dev/recovery override.
        #[arg(long)]
        allow_unauthenticated_api: bool,
    },
    /// Read the configuration, compile the components, verify their imports and exit without starting the server.
    Verify(VerifyArgs),
}

#[derive(Debug, clap::Args)]
#[expect(clippy::struct_excessive_bools)]
pub(crate) struct VerifyArgs {
    /// Delete both the codegen cache and the OCI image cache before verifying.
    #[arg(long)]
    pub(crate) clean_cache: bool,
    /// Delete only the codegen cache before verifying; OCI image cache is kept.
    #[arg(long)]
    pub(crate) clean_codegen_cache: bool,
    /// Path to the server configuration file (server.toml). If omitted, built-in defaults are used.
    #[arg(short, long)]
    pub(crate) server_config: Option<PathBuf>,
    /// Path to the deployment TOML file. If omitted, the database's Enqueued deployment is used,
    /// falling back to the Active deployment. Errors if neither is found.
    #[arg(short, long)]
    pub(crate) deployment: Option<PathBuf>,
    /// Tolerate runtime requirements unavailable on this server while verifying.
    #[arg(long)]
    pub(crate) allow_unavailable_runtime_config: bool,
    /// Do not fail when a component's imports/exports fail type checking against the deployment.
    #[arg(long)]
    pub(crate) suppress_type_checking_errors: bool,
    /// Skip opening the sqlite database and validating its schema.
    #[arg(long)]
    pub(crate) skip_db: bool,
    /// Rewrite mismatched deployment digests and, when `--server-config` is passed, its exec allowlist.
    #[arg(long, requires = "deployment")]
    pub(crate) fix: bool,
}

#[derive(Debug, clap::Args)]
#[expect(clippy::struct_excessive_bools)]
pub(crate) struct DeploymentVerifyArgs {
    /// Delete both the codegen cache and the OCI image cache before verifying.
    #[arg(long)]
    pub(crate) clean_cache: bool,
    /// Delete only the codegen cache before verifying; OCI image cache is kept.
    #[arg(long)]
    pub(crate) clean_codegen_cache: bool,
    /// Path to the server configuration file (server.toml). If omitted, built-in defaults are used.
    #[arg(short, long)]
    pub(crate) server_config: Option<PathBuf>,
    /// Path to the local deployment TOML file.
    #[arg(short, long)]
    pub(crate) deployment: PathBuf,
    /// Tolerate runtime requirements unavailable on this server while verifying.
    #[arg(long)]
    pub(crate) allow_unavailable_runtime_config: bool,
    /// Do not fail when a component's imports/exports fail type checking against the deployment.
    #[arg(long)]
    pub(crate) suppress_type_checking_errors: bool,
    /// Rewrite mismatched deployment digests and, when `--server-config` is passed, its exec allowlist.
    #[arg(long)]
    pub(crate) fix: bool,
}

#[derive(Debug, clap::Subcommand)]
pub(crate) enum Component {
    /// List components.
    List {
        /// Address of the obelisk server
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
        /// Show component imports
        #[arg(short, long)]
        imports: bool,
        /// Show auto-generated export extensions
        #[arg(short, long)]
        extensions: bool,
    },
    /// Push a WASM component to an OCI registry with deployment metadata.
    Push {
        /// Component name in the input deployment TOML
        #[arg(required(true))]
        component_name: String,
        /// OCI reference with `oci://` prefix. Example: `oci://docker.io/repo/image:tag`
        #[arg(required(true), value_parser = parse_oci_reference)]
        location: oci_client::Reference,
        /// Path to the input deployment TOML file.
        #[arg(long, short, required = true)]
        deployment: PathBuf,
    },
    /// Add a component to the deployment TOML configuration file from an OCI reference.
    Add {
        /// OCI reference with `oci://` prefix. Example: `oci://docker.io/repo/image:tag`
        #[arg(required(true), value_parser = parse_oci_reference)]
        location: oci_client::Reference,
        /// Component name in the target deployment TOML
        #[arg(required(true))]
        component_name: String,
        /// Path to the target deployment TOML file.
        #[arg(long, short, required = true)]
        deployment: PathBuf,
        /// Pin the location with the manifest digest (e.g. `image:tag@sha256:...`).
        #[arg(long)]
        locked: bool,
    },
}

#[derive(Debug, Clone)]
pub enum FunctionFqnOrShort {
    Ffqn(FunctionFqn),
    Short {
        ifc_name: String,
        function_name: String,
    }, // starts with `.../` prefix
}
impl FromStr for FunctionFqnOrShort {
    type Err = FunctionFqnParseError;

    fn from_str(input: &str) -> Result<Self, Self::Err> {
        const PREFIX: &str = ".../";

        if let Some(rest) = input.strip_prefix(PREFIX) {
            let Some((ifc_name, fn_name)) = rest.split_once('.') else {
                return Err(FunctionFqnParseError::DelimiterNotFound(input.to_string()));
            };
            // Ensure exactly two parts
            if fn_name.contains('.') {
                return Err(FunctionFqnParseError::DelimiterFoundInFunctionName(
                    input.to_string(),
                ));
            }

            Ok(FunctionFqnOrShort::Short {
                ifc_name: ifc_name.to_string(),
                function_name: fn_name.to_string(),
            })
        } else {
            Ok(FunctionFqnOrShort::Ffqn(FunctionFqn::from_str(input)?))
        }
    }
}

/// Minimum log level for `execution logs`.
/// One of: `trace`, `debug`, `info`, `warn`, `error`, `off`.
#[derive(Debug, Clone, Copy, strum::EnumString)]
#[strum(serialize_all = "snake_case")]
pub(crate) enum LogLevelArg {
    Trace,
    Debug,
    Info,
    Warn,
    Error,
    /// Disable structured log output.
    Off,
}

/// Log stream type for `execution logs`.
/// One of: `stdout`, `stderr`, `none`.
#[derive(Debug, Clone, Copy, strum::EnumString)]
#[strum(serialize_all = "snake_case")]
pub(crate) enum LogStreamTypeArg {
    Stdout,
    Stderr,
    None,
}

#[derive(Debug, clap::Subcommand)]
pub(crate) enum Execution {
    /// List recent executions.
    List {
        /// Address of the obelisk server.
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
        /// Filter by function FFQN prefix.
        /// Accepts any prefix of a fully-qualified function name, e.g.: `namespace:`.
        /// Short `.../ifc.fn` form is not supported.
        #[arg(long = "ffqn", value_name = "FFQN_PREFIX")]
        ffqn_prefix: Option<String>,
        /// Filter by execution id prefix.
        /// Useful to find all child executions if --show-derived flag is enabled.
        #[arg(long = "execution-id", short, value_name = "EXECUTION_ID_PREFIX")]
        execution_id_prefix: Option<String>,
        /// Include child (derived) executions spawned by workflows.
        /// By default only top-level executions are shown.
        #[arg(long)]
        show_derived: bool,
        /// Hide finished executions.
        #[arg(long)]
        hide_finished: bool,
        /// Number of executions to return.
        #[arg(long, default_value = "20")]
        limit: u16,
        /// Output as JSON instead of human-readable text.
        #[arg(short, long)]
        json: bool,
    },
    /// Fetch structured logs for an execution.
    Logs {
        /// Address of the obelisk server.
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
        /// Execution ID to fetch logs for.
        #[arg(value_name = "EXECUTION_ID")]
        execution_id: ExecutionId,
        /// Include logs from child (derived) executions.
        #[arg(long)]
        show_derived: bool,
        /// Minimum log level: trace, debug, info, warn, error, off.
        /// `off` disables structured log output entirely.
        #[arg(long, value_name = "LEVEL", default_value = "debug")]
        level: LogLevelArg,
        /// Select which stream output to show: stdout, stderr, none.
        /// If not specified, both stdout and stderr are shown.
        /// Use `none` to hide all stream output.
        #[arg(long = "stream-type", value_name = "TYPE")]
        stream_type: Option<LogStreamTypeArg>,
        /// Show the run ID in each log line.
        #[arg(long)]
        show_run_id: bool,
        /// Only show entries created after this timestamp (RFC3339, e.g. 2026-04-14T10:00:00Z).
        #[arg(long, value_name = "TIMESTAMP")]
        after: Option<String>,
        /// Poll for new log entries until the execution finishes.
        #[arg(long)]
        follow: bool,
        /// Number of log entries to return per request (default: 20, max: 200).
        #[arg(long, default_value = "20")]
        limit: u16,
        /// Output as JSON instead of human-readable text.
        #[arg(short, long)]
        json: bool,
    },
    /// Show the execution event log (history).
    Events {
        /// Address of the obelisk server.
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
        /// Execution ID.
        #[arg(value_name = "EXECUTION_ID")]
        execution_id: ExecutionId,
        /// Start from this event version (inclusive).
        #[arg(long, value_name = "VERSION")]
        from: Option<u32>,
        /// Number of events to return.
        #[arg(long, default_value = "20")]
        limit: u16,
        /// Output as JSON instead of human-readable text.
        #[arg(short, long)]
        json: bool,
    },
    /// Show join-set responses for an execution (child results, delay completions).
    Responses {
        /// Address of the obelisk server.
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
        /// Execution ID.
        #[arg(value_name = "EXECUTION_ID")]
        execution_id: ExecutionId,
        /// Start from this response cursor (inclusive).
        #[arg(long, value_name = "CURSOR")]
        from: Option<u32>,
        /// Number of responses to return.
        #[arg(long, default_value = "20")]
        limit: u16,
        /// Output as JSON instead of human-readable text.
        #[arg(short, long)]
        json: bool,
    },
    /// Submit a new execution and optionally follow its status stream until it finishes.
    Submit {
        /// Address of the obelisk server
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
        /// Use this explicit execution ID instead of having the server assign one. Useful for idempotency. See `obelisk generate execution-id`.
        #[arg(short, long)]
        execution_id: Option<ExecutionId>,
        /// Function to invoke, either as a fully qualified name (`ns:pkg/ifc.fn`)
        /// or shortened to `.../ifc.fn` when the interface name is unambiguous.
        #[arg(value_name = "FUNCTION")]
        ffqn: FunctionFqnOrShort,
        /// Follow the stream of events until the execution finishes.
        #[arg(short, long)]
        follow: bool,
        /// Do not attempt to reconnect on connection error while following the status stream.
        #[arg(long, requires = "follow")]
        no_reconnect: bool,
        /// Create the execution in paused state so it won't run until explicitly unpaused or advanced.
        #[arg(long)]
        paused: bool,
        /// Output events as JSON in Web API format instead of human-readable text.
        #[arg(short, long)]
        json: bool,
        /// Accepted Parameter Formats:
        ///
        /// - JSON array string, e.g. '["first", "second", null, 1]'
        ///
        /// - File reference prefixed with @, e.g. @file.json (file must contain a valid JSON array)
        ///
        /// - Multiple arguments after --, e.g. -- '"first"' @secondparam.json null 1
        ///
        /// - For functions with no parameters: [] (JSON array variant) or no arguments.
        #[arg(name = "parameters")]
        params: Vec<String>,
    },
    /// Write a return value or an execution error to an already created stubbed execution,
    /// unblocking any parent workflow that is awaiting it.
    Stub(Stub),
    /// Get the current state of an execution, optionally following status changes.
    #[command(alias = "get")]
    Status {
        /// Address of the obelisk server
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
        /// Follow the status stream until the execution finishes.
        #[arg(short, long)]
        follow: bool,
        /// Execution ID to look up.
        execution_id: ExecutionId,
        /// Do not attempt to reconnect on connection error while following the status stream.
        #[arg(long, requires = "follow")]
        no_reconnect: bool,
        /// Output as JSON instead of human-readable text.
        #[arg(short, long)]
        json: bool,
    },
    /// Get the final result of an execution.
    Result {
        /// Address of the obelisk server
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
        /// Follow until the execution finishes and the final result is available.
        #[arg(short, long)]
        follow: bool,
        /// Execution ID to look up.
        execution_id: ExecutionId,
        /// Do not attempt to reconnect on connection error while following the execution.
        #[arg(long, requires = "follow")]
        no_reconnect: bool,
        /// Output as JSON instead of human-readable text.
        #[arg(short, long)]
        json: bool,
    },
    /// Request cancellation of a running activity or pending delay.
    Cancel(CancelCommand),
    /// Pause a workflow execution or a pending delay.
    Pause {
        /// Address of the obelisk server
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
        /// Execution ID (`E_01`...) or delay ID (`Delay_01`...) to pause.
        #[arg(value_name = "ID")]
        id: ExecutionIdOrDelayId,
    },
    /// Resume a previously paused workflow execution or delay.
    Unpause {
        /// Address of the obelisk server
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
        /// Execution ID (`E_01`...) or delay ID (`Delay_01`...) to unpause.
        #[arg(value_name = "ID")]
        id: ExecutionIdOrDelayId,
    },
    /// Replay a workflow execution from its execution log, checking for non-determinism.
    Replay {
        /// Address of the obelisk server
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
        /// Execution ID to replay.
        execution_id: ExecutionId,
        /// Output as JSON instead of human-readable text.
        #[arg(short, long)]
        json: bool,
    },
    /// Replay a workflow and persist its call-site backtraces.
    PersistBacktraces {
        /// Address of the obelisk server
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
        /// Execution ID whose backtraces should be persisted.
        execution_id: ExecutionId,
    },
    /// Advance a paused workflow execution by replaying it and applying the next captured writes.
    Advance {
        /// Address of the obelisk server
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
        /// Execution ID to advance.
        execution_id: ExecutionId,
        /// Output as JSON instead of human-readable text.
        #[arg(short, long)]
        json: bool,
        /// Send only the first N captured writes from replay to advance.
        #[arg(long)]
        trim: Option<usize>,
        /// Pause both replayed child executions and delay requests when applying the advance.
        #[arg(short = 'p', long = "pause-all", visible_alias = "pause")]
        pause_all: bool,
        /// Rewrite replayed submitted executions so they are created paused when advance is applied.
        #[arg(long)]
        pause_submitted_executions: bool,
        /// Rewrite replayed delay requests so they are created paused when advance is applied.
        #[arg(long)]
        pause_delays: bool,
        /// Advance even if replay finishes with an execution failure, persisting it.
        #[arg(long)]
        force: bool,
    },
    /// Upgrade a workflow execution to the current component version in the active deployment.
    ///
    /// Looks up the execution's FFQN, finds the component that exports it in the active
    /// deployment, and upgrades the execution from its current component digest to the new one.
    Upgrade {
        /// Address of the obelisk server
        #[arg(short, long, default_value = "http://127.0.0.1:5005")]
        api_url: String,
        /// Execution ID to upgrade.
        execution_id: ExecutionId,
        /// Skip the determinism check during upgrade.
        #[arg(long)]
        skip_determinism_check: bool,
    },
}

pub(crate) mod params {
    use clap::error::ErrorKind;
    use serde_json::Value;

    pub(crate) fn parse_params(params: Vec<String>) -> Result<Vec<serde_json::Value>, clap::Error> {
        if params.is_empty() {
            Ok(vec![]) // no params, does not matter if `--` was present.
        } else if params.len() == 1 && !dashdash() {
            let mut params = params;
            let json_array = params.pop().expect("checked that len == 1");
            // Single JSON Array, or a `@`-prefixed file containing the array.
            let json_array = if let Some(file_path) = json_array.strip_prefix('@') {
                std::fs::read_to_string(file_path).map_err(|err| {
                    clap::Error::raw(
                        ErrorKind::Io,
                        format!(
                            "parameter parsing failed: failed to read file '{file_path}': {err}"
                        ),
                    )
                })?
            } else {
                json_array
            };
            let json_value = serde_json::from_str(&json_array).map_err(|err| {
                clap::Error::raw(
                    ErrorKind::ValueValidation,
                    format!("Invalid JSON array for parameters: {err}"),
                )
            })?;
            let Value::Array(params) = json_value else {
                return Err(clap::Error::raw(
                    ErrorKind::ValueValidation,
                    "Parameter provided as JSON must be a JSON array.",
                ));
            };
            Ok(params)
        } else {
            // Fallback to raw arguments. Each argument is interpreted as a JSON value or a file starting with `@` that contains the JSON.
            let mut parsed_params: Vec<Value> = Vec::new();
            for (idx, arg) in params.into_iter().enumerate() {
                let arg = if let Some(file_path) = arg.strip_prefix('@') {
                    std::fs::read_to_string(file_path).map_err(|err| {
                        clap::Error::raw(
                            ErrorKind::Io,
                            format!(
                                "{}-th parameter parsing failed: failed to read file '{file_path}': {err}",
                                idx + 1
                            ),
                        )
                    })?
                } else {
                    arg
                };
                let json = serde_json::from_str(&arg).map_err(|err| {
                    clap::Error::raw(
                        ErrorKind::ValueValidation,
                        format!(
                            "cannot parse {}-th parameter `{arg}` as JSON -  {err}",
                            idx + 1
                        ),
                    )
                })?;
                parsed_params.push(json);
            }
            Ok(parsed_params)
        }
    }

    fn dashdash() -> bool {
        // Ambigous: Either the single JSON Array representing all parameters,
        // OR `-- "first-and-only-json-param"`
        let mut rev_arg_iter = std::env::args().rev();
        rev_arg_iter.next().expect("last arg must be present");
        let maybe_separator = rev_arg_iter.next().expect("last-1 arg must be present");
        maybe_separator == "--"
    }
}

#[derive(Debug, clap::Args)]
#[command()]
pub(crate) struct Stub {
    /// Address of the obelisk server
    #[arg(short, long, default_value = "http://127.0.0.1:5005")]
    pub(crate) api_url: String,
    /// Execution ID of the stub execution waiting for its return value.
    #[arg(value_name = "EXECUTION_ID")]
    pub(crate) execution_id: ExecutionIdDerived,

    /// Stub a return value encoded as JSON
    #[arg(value_name = "RETURN_VAL")]
    pub(crate) return_value: String,
}

/// An ID that is either an execution ID or a delay ID.
#[derive(Debug, Clone)]
pub(crate) enum ExecutionIdOrDelayId {
    Execution(ExecutionId),
    Delay(DelayId),
}

impl std::str::FromStr for ExecutionIdOrDelayId {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        if let Ok(execution_id) = s.parse::<ExecutionId>() {
            Ok(Self::Execution(execution_id))
        } else if let Ok(delay_id) = s.parse::<DelayId>() {
            Ok(Self::Delay(delay_id))
        } else {
            Err(format!(
                "cannot parse `{s}` as an execution ID or a delay ID"
            ))
        }
    }
}

#[derive(Debug, clap::Args)]
#[command()]
#[expect(clippy::doc_markdown)]
pub(crate) struct CancelCommand {
    /// Address of the obelisk server
    #[arg(short, long, default_value = "http://127.0.0.1:5005")]
    pub(crate) api_url: String,
    /// Execution id of an activity (E_01...) or a delay request (Delay_01...)
    #[arg(value_name = "ID")]
    pub(crate) id: ExecutionIdOrDelayId,
}
