package Mediabot::Plugin::ScriptDryRun;

use strict;
use warnings;
use utf8;
use Time::HiRes qw(time);

our $VERSION = '0.001';

# ---------------------------------------------------------------------------
# Mediabot::Plugin::ScriptDryRun
# ---------------------------------------------------------------------------
# Trusted in-process bridge from EventBus to external Perl/Python/Tcl scripts.
#
# The historical ScriptDryRun name is retained for configuration and partyline
# compatibility, but the plugin now supports two explicit modes:
#   - dry-run: execute the script and validate/plan its returned actions only;
#   - apply: execute the script and pass validated actions to ScriptActionRunner.
# IRC reply/notice output additionally requires ALLOW_IRC=yes, and apply mode is
# scoped by COMMANDS/ROUTES when APPLY_REQUIRE_SCOPE is enabled (the default).
# ---------------------------------------------------------------------------

sub register {
    my ($class, $bot, %opts) = @_;

    my $self = bless {
        bot             => $bot,
        manager         => $opts{manager},
        plugin_name     => $opts{name} || __PACKAGE__,
        script_path     => _conf_get_first(
            $bot ? $bot->{conf} : undef,
            'plugins.ScriptDryRun.SCRIPT',
            'plugins.ScriptDryRun.script',
            'plugins.script_dryrun.SCRIPT',
            'plugins.script_dryrun.script',
            'SCRIPT_DRYRUN_SCRIPT',
            'SCRIPT_DRYRUN_PATH',
        ),
        command_filter_raw => _conf_get_first(
            $bot ? $bot->{conf} : undef,
            'plugins.ScriptDryRun.COMMANDS',
            'plugins.ScriptDryRun.commands',
            'plugins.script_dryrun.COMMANDS',
            'plugins.script_dryrun.commands',
            'SCRIPT_DRYRUN_COMMANDS',
        ),
        command_routes_raw => _conf_get_first(
            $bot ? $bot->{conf} : undef,
            'plugins.ScriptDryRun.ROUTES',
            'plugins.ScriptDryRun.routes',
            'plugins.script_dryrun.ROUTES',
            'plugins.script_dryrun.routes',
            'SCRIPT_DRYRUN_ROUTES',
        ),
        action_mode_raw => _conf_get_first(
            $bot ? $bot->{conf} : undef,
            'plugins.ScriptDryRun.ACTION_MODE',
            'plugins.ScriptDryRun.action_mode',
            'plugins.script_dryrun.ACTION_MODE',
            'plugins.script_dryrun.action_mode',
            'SCRIPT_DRYRUN_ACTION_MODE',
        ),
        allow_irc_raw => _conf_get_first(
            $bot ? $bot->{conf} : undef,
            'plugins.ScriptDryRun.ALLOW_IRC',
            'plugins.ScriptDryRun.allow_irc',
            'plugins.script_dryrun.ALLOW_IRC',
            'plugins.script_dryrun.allow_irc',
            'SCRIPT_DRYRUN_ALLOW_IRC',
        ),
        apply_require_scope_raw => _conf_get_first(
            $bot ? $bot->{conf} : undef,
            'plugins.ScriptDryRun.APPLY_REQUIRE_SCOPE',
            'plugins.ScriptDryRun.apply_require_scope',
            'plugins.script_dryrun.APPLY_REQUIRE_SCOPE',
            'plugins.script_dryrun.apply_require_scope',
            'SCRIPT_DRYRUN_APPLY_REQUIRE_SCOPE',
        ),
        action_mode => 'dry-run',
        allow_irc   => 0,
        apply_require_scope => 0,
        command_filter => undef,
        command_routes => undef,
        observed_public => 0,
        skipped_public  => 0,
        filtered_public => 0,
        last_result     => undef,
        last_error      => undef,
    }, $class;

    # mb261-B1: SCRIPT is a single script path, not a list-like option.
    # Config loaders can still hand us ARRAY refs; normalize to the first
    # meaningful scalar value here so we never pass ARRAY(...)/HASH(...) to
    # ScriptRunner path validation or route fallback.
    $self->{script_path} = _first_config_scalar($self->{script_path});

    # mb182-B1: optional command allow-list for the ScriptDryRun bridge.
    # Empty or missing filter keeps the previous behavior: observe all commands.
    $self->{command_filter} = _make_command_filter($self->{command_filter_raw});

    # Optional command-to-script route map. One trusted bridge instance can
    # dispatch different commands to different Perl/Python/Tcl scripts while
    # keeping the same path, protocol, scope and action-application guards.
    $self->{command_routes} = _make_command_routes($self->{command_routes_raw});

    # mb187-B1: explicit action mode gate. Default is dry-run. Real application
    # is only possible with ACTION_MODE=apply, and IRC output still requires
    # ALLOW_IRC to be truthy.
    $self->{action_mode} = _normalize_action_mode($self->{action_mode_raw});
    $self->{allow_irc}   = _truthy($self->{allow_irc_raw});

    # mb189-B1 + A3 (mb225): optional extra safety gate, now ENABLED BY DEFAULT.
    # When enabled, ACTION_MODE=apply is refused unless COMMANDS or ROUTES
    # restrict the public-command scope. An operator can still opt out
    # explicitly with APPLY_REQUIRE_SCOPE=no. This closes the dangerous path
    # where an unscoped apply-mode config would apply actions for every command.
    $self->{apply_require_scope} = _truthy_with_default($self->{apply_require_scope_raw}, 1);

    if ($bot && $bot->can('events') && $bot->events) {
        # mb242-B2: keep the EventBus listener entry so a replaced plugin
        # instance can unregister its own observer without removing the new
        # replacement listener. This keeps PluginManager replace reloads from
        # accumulating duplicate ScriptDryRun observers.
        $self->{listener_entry} = $bot->events->on(
            public_command_observed => sub {
                my ($ctx) = @_;
                return $self->observe_public_command($ctx);
            },
            name   => 'script-dryrun-public-command-observer',
            plugin => __PACKAGE__,
        );
    }

    return $self;
}

sub unregister {
    my ($self, %opts) = @_;

    my $entry = $self->{listener_entry};
    return 0 unless ref($entry) eq 'HASH';

    my $bot = $self->{bot};
    return 0 unless $bot && $bot->can('events') && $bot->events && $bot->events->can('off');

    my $removed = eval { $bot->events->off(public_command_observed => $entry) } || 0;
    delete $self->{listener_entry} if $removed;

    return $removed;
}

sub plugin_enabled {
    my ($self) = @_;

    # mb245-B1: a loaded EventBus listener must still honour PluginManager's
    # enabled flag.  PluginManager::disable() only flips manager metadata; it
    # does not rebuild EventBus subscriptions.  Without this guard, a disabled
    # ScriptDryRun plugin could keep intercepting public commands through its
    # existing listener.  If the plugin is not manager-registered (direct unit
    # construction), keep the historical fail-open behaviour.
    my $manager = $self->{manager};
    return 1 unless $manager && eval { $manager->can('is_enabled') };

    my $name = $self->{plugin_name} || __PACKAGE__;
    my $registered = eval { $manager->can('plugin') ? $manager->plugin($name) : undef };
    return 1 unless $registered;

    my $enabled = eval { $manager->is_enabled($name) };
    return 1 if $@;

    return $enabled ? 1 : 0;
}

sub _conf_get_first {
    my ($conf, @keys) = @_;

    return undef unless $conf;

    for my $key (@keys) {
        next unless defined $key && length $key;

        my $value;
        my $ok = eval {
            if (ref($conf) eq 'HASH') {
                $value = $conf->{$key};
            }
            elsif ($conf->can('get')) {
                $value = $conf->get($key);
            }
            1;
        };

        next unless $ok;
        return $value if _conf_value_has_meaningful_scalar($value);
    }

    return undef;
}

sub _conf_value_has_meaningful_scalar {
    my ($value) = @_;

    # mb264-B1: config fallback selection must not rely on Perl ref
    # stringification. An empty ARRAY ref or a HASH ref in the first key must
    # not mask a later legacy/alias key that contains the real value. Keep
    # ARRAY support for Config::Simple list-like values, but require at least
    # one meaningful flattened scalar before accepting the candidate key.
    for my $candidate (_flatten_config_values($value)) {
        next unless defined $candidate;
        next if ref($candidate);

        my $v = "$candidate";
        $v =~ s/^\s+|\s+$//g;
        return 1 if length $v;
    }

    return 0;
}

sub _truthy {
    my ($value) = @_;

    # mb195-B1: Config::Simple can return ARRAY refs for boolean plugin
    # settings too. Never stringify ARRAY refs into "ARRAY(0x...)" here.
    for my $candidate (_flatten_config_values($value)) {
        next unless defined $candidate;

        my $v = lc "$candidate";
        $v =~ s/^\s+|\s+$//g;
        next unless length $v;

        return 1 if $v =~ /\A(?:1|yes|true|on|enabled|enable)\z/;
        return 0 if $v =~ /\A(?:0|no|false|off|disabled|disable)\z/;
    }

    return 0;
}

# A3 (mb225): like _truthy, but distinguishes "not configured at all" (return
# the supplied default) from an explicit true/false. Used so a safety gate can
# default to enabled while still honoring an explicit opt-out.
sub _truthy_with_default {
    my ($value, $default) = @_;

    for my $candidate (_flatten_config_values($value)) {
        next unless defined $candidate;

        my $v = lc "$candidate";
        $v =~ s/^\s+|\s+$//g;
        next unless length $v;

        return 1 if $v =~ /\A(?:1|yes|true|on|enabled|enable)\z/;
        return 0 if $v =~ /\A(?:0|no|false|off|disabled|disable)\z/;
    }

    return $default ? 1 : 0;
}


sub _first_config_scalar {
    my ($value) = @_;

    # mb261-B2: only plain scalar config values can become SCRIPT paths.
    # COMMANDS/ROUTES are list-like and keep ARRAY support elsewhere, but SCRIPT
    # must never be left as an ARRAY/HASH ref that later stringifies into
    # ARRAY(0x...) or HASH(0x...) at the ScriptRunner boundary.
    for my $candidate (_flatten_config_values($value)) {
        next unless defined $candidate;
        next if ref($candidate);

        my $v = "$candidate";
        $v =~ s/^\s+|\s+$//g;
        next unless length $v;

        return $v;
    }

    return undef;
}

sub _normalize_action_mode {
    my ($value) = @_;

    # mb195-B2: ACTION_MODE can also be delivered as an ARRAY ref when coming
    # from sectioned Config::Simple data. Use the first meaningful flattened
    # value and keep the historical dry-run default on anything unknown.
    for my $candidate (_flatten_config_values($value)) {
        next unless defined $candidate;

        my $v = lc "$candidate";
        $v =~ s/^\s+|\s+$//g;
        $v =~ s/_/-/g;
        next unless length $v;

        return 'apply'   if $v eq 'apply';
        return 'dry-run' if $v eq 'dry-run' || $v eq 'dryrun' || $v eq 'dry';
    }

    return 'dry-run';
}


sub _flatten_config_values {
    my ($value) = @_;

    return () unless defined $value;

    # mb194-B1: Config::Simple can return ARRAY refs for comma-separated
    # values. ScriptDryRun uses COMMANDS and ROUTES heavily, so flatten arrays
    # before any split/parsing step.
    my @queue = ($value);
    my @out;

    while (@queue) {
        my $entry = shift @queue;
        next unless defined $entry;

        if (ref($entry) eq 'ARRAY') {
            unshift @queue, @$entry;
            next;
        }

        # mb261-B3: skip non-ARRAY refs instead of stringifying HASH(...) or
        # blessed config objects into COMMANDS/ROUTES/booleans/SCRIPT helpers.
        next if ref($entry);

        push @out, $entry;
    }

    return @out;
}

sub _split_list {
    my ($raw) = @_;

    my @items;
    for my $value (_flatten_config_values($raw)) {
        for my $part (split /[,\s]+/, "$value") {
            $part =~ s/^\s+|\s+$//g;
            next unless length $part;
            push @items, $part;
        }
    }

    return @items;
}


sub _normalize_command_name {
    my ($command) = @_;

    return '' unless defined $command;

    # mb268-B1: command names are protocol/control values, not arbitrary JSON or
    # Perl structures.  Do not stringify ARRAY/HASH/blessed refs into values such
    # as ARRAY(0x...) and then let a fallback SCRIPT observe/apply them as if they
    # were real IRC commands.
    # mb281-B1: keep the command token single-line and single-token before it can
    # drive ScriptDryRun routing, logging, ownership, or the JSON payload sent to
    # external Perl/Python/Tcl scripts. Real IRC commands are separated from args
    # before this point, so embedded whitespace/control bytes are malformed.
    return '' if ref($command);

    my $raw = "$command";
    return '' if $raw =~ /[\r\n\0]/;

    my $value = lc $raw;
    $value =~ s/^\s+|\s+$//g;
    $value =~ s/^[.!]+//;

    return '' unless length $value;
    return '' if $value =~ /\s/;

    return $value;
}

sub _make_command_filter {
    my ($raw) = @_;

    my %filter;
    for my $command (_split_list($raw)) {
        my $normalized = _normalize_command_name($command);
        next unless length $normalized;
        $filter{$normalized} = 1;
    }

    return \%filter;
}


sub _make_command_routes {
    my ($raw) = @_;

    my %routes;

    # mb195-B3: ROUTES may arrive as an ARRAY ref with Config::Simple when the
    # value contains commas. Parse each flattened value instead of stringifying
    # the ARRAY ref into "ARRAY(0x...)".
    for my $value (_flatten_config_values($raw)) {
        next unless defined $value;

        for my $entry (split /[,\n;]+/, "$value") {
            $entry =~ s/^\s+|\s+$//g;
            next unless length $entry;

            my ($command, $script) = split /\s*=\s*/, $entry, 2;
            next unless defined $command && defined $script;

            my $normalized = _normalize_command_name($command);
            $script =~ s/^\s+|\s+$//g;

            next unless length $normalized && length $script;
            $routes{$normalized} = $script;
        }
    }

    return \%routes;
}


sub _ctx_value {
    my ($ctx, @names) = @_;

    return undef unless $ctx;

    for my $name (@names) {
        next unless defined $name && length $name;

        my $value;

        if (ref($ctx) eq 'HASH') {
            $value = $ctx->{$name};
            return $value if defined $value;
        }

        my $hash_ok = eval {
            $value = $ctx->{$name};
            1;
        };
        return $value if $hash_ok && defined $value;

        my $method_ok = eval { $ctx->can($name) };
        if ($method_ok) {
            my $method_value = eval { $ctx->$name() };
            return $method_value if defined $method_value;
        }
    }

    return undef;
}

sub _ctx_mark_scriptdryrun_handled {
    my ($ctx, $result, $error) = @_;

    return unless $ctx;

    my $ok = eval {
        $ctx->{scriptdryrun_handled} = 1;
        $ctx->{scriptdryrun_result}  = $result if defined $result;
        $ctx->{scriptdryrun_error}   = $error  if defined $error && length "$error";
        1;
    };

    return $ok ? 1 : 0;
}

# mb196-B1: lightweight ScriptDryRun runtime logging.
# This is intentionally defensive: logging must never break command dispatch.
sub _log_bot {
    my ($self, $level, $message) = @_;

    return unless defined $message && length "$message";
    $level = 4 unless defined $level && $level =~ /\A\d+\z/;

    my $bot = $self->{bot};
    return unless $bot;

    my $logger = eval { $bot->{logger} };
    if ($logger && eval { $logger->can('log') }) {
        eval { $logger->log($level, $message); 1 };
        return;
    }
}

sub _count_arrayref {
    my ($value) = @_;
    return 0 unless ref($value) eq 'ARRAY';
    return scalar @$value;
}


sub _elapsed_ms {
    my ($started_at) = @_;

    return 0 unless defined $started_at;

    my $elapsed = int(((time() - $started_at) * 1000) + 0.5);
    return $elapsed < 0 ? 0 : $elapsed;
}

# mb202-B1: centralize runtime logging with elapsed_ms so live debugging can
# distinguish command routing delays, script runtime, and action application.
sub _log_script_result {
    my ($self, $command, $started_at, $script_result) = @_;

    my $elapsed = _elapsed_ms($started_at);
    $self->_log_bot(4, "PUBLIC(scriptdryrun): script_result command=$command elapsed_ms=$elapsed " . _script_result_summary($script_result));
}

sub _log_action_plan {
    my ($self, $command, $started_at, $action_plan) = @_;

    my $elapsed = _elapsed_ms($started_at);
    $self->_log_bot(4, "PUBLIC(scriptdryrun): action_plan command=$command elapsed_ms=$elapsed " . _action_plan_summary($action_plan));
}

sub _script_result_summary {
    my ($result) = @_;

    return 'result=<not-a-hash>' unless ref($result) eq 'HASH';

    my $ok      = $result->{ok}      ? 1 : 0;
    my $timeout = $result->{timeout} ? 1 : 0;
    my $exit    = defined $result->{exit_code} ? $result->{exit_code} : '-';
    my $stderr  = defined $result->{stderr} && length $result->{stderr} ? $result->{stderr} : '';
    $stderr =~ s/[\r\n]+/ /g;
    $stderr = substr($stderr, 0, 180) if length($stderr) > 180;

    my $response = ref($result->{response}) eq 'HASH' ? $result->{response} : {};
    my $actions  = _count_arrayref($response->{actions});
    my $errors   = _count_arrayref($response->{errors});

    return "ok=$ok timeout=$timeout exit=$exit actions=$actions errors=$errors stderr=$stderr";
}

sub _action_plan_summary {
    my ($plan) = @_;

    return 'plan=<not-a-hash>' unless ref($plan) eq 'HASH';

    my $ok           = $plan->{ok}         ? 1 : 0;
    my $applied_ok   = $plan->{applied_ok} ? 1 : 0;
    my $planned      = _count_arrayref($plan->{planned});
    my $applied      = _count_arrayref($plan->{applied});
    my $errors       = _count_arrayref($plan->{errors});
    my $apply_errors = _count_arrayref($plan->{apply_errors});

    return "ok=$ok applied_ok=$applied_ok planned=$planned applied=$applied errors=$errors apply_errors=$apply_errors";
}


sub _ctx_command {
    my ($ctx) = @_;

    # mb269-B1: command/cmd are scalar protocol fields.  A malformed ref in
    # the preferred key must not mask a scalar fallback key, and must never be
    # stringified into ARRAY(...)/HASH(...) before command filtering/routing.
    for my $name (qw(command cmd)) {
        my $value = _ctx_value($ctx, $name);
        return "$value" if defined $value && !ref($value) && length "$value";
    }

    my $cmd_obj = _ctx_value($ctx, 'command_obj');
    if ($cmd_obj) {
        for my $method (qw(name command cmd)) {
            next unless eval { $cmd_obj->can($method) };
            my $value = eval { $cmd_obj->$method() };
            return "$value" if defined $value && !ref($value) && length "$value";
        }

        my $hash_value = eval { $cmd_obj->{name} };
        return "$hash_value" if defined $hash_value && !ref($hash_value) && length "$hash_value";
    }

    return undef;
}

sub _ctx_scalar_value {
    my ($ctx, @names) = @_;

    # mb269-B2: public-command envelope fields sent to external scripts must be
    # scalar text values.  Ignore refs and continue to fallback aliases instead
    # of JSON-encoding nested Perl structures or dying on blessed objects.
    for my $name (@names) {
        my $value = _ctx_value($ctx, $name);
        return "$value" if defined $value && !ref($value) && length "$value";
    }

    return undef;
}

sub _sanitize_ctx_args {
    my ($args) = @_;

    return [] unless ref($args) eq 'ARRAY';

    my @clean;
    for my $arg (@$args) {
        next unless defined $arg;
        next if ref($arg);
        push @clean, "$arg";
    }

    return \@clean;
}

sub _ctx_args {
    my ($ctx) = @_;

    # mb269-B3: command arguments are an array of scalar strings in the external
    # script JSON envelope.  Keep valid scalars, drop ARRAY/HASH/blessed refs,
    # and let command_obj/hash fallbacks work when the first source is malformed.
    my $args = _ctx_value($ctx, 'args');
    return _sanitize_ctx_args($args) if ref($args) eq 'ARRAY';

    my $cmd_obj = _ctx_value($ctx, 'command_obj');
    if ($cmd_obj) {
        for my $method (qw(args argv)) {
            next unless eval { $cmd_obj->can($method) };
            my $value = eval { $cmd_obj->$method() };
            return _sanitize_ctx_args($value) if ref($value) eq 'ARRAY';
        }

        my $hash_value = eval { $cmd_obj->{args} };
        return _sanitize_ctx_args($hash_value) if ref($hash_value) eq 'ARRAY';
    }

    return [];
}

sub observe_public_command {
    my ($self, $ctx) = @_;

    $self->{observed_public}++;

    my $bot = $self->{bot};

    unless ($self->plugin_enabled) {
        $self->{skipped_public}++;
        $self->{last_error} = 'ScriptDryRun plugin is disabled';
        return undef;
    }

    unless ($bot && $bot->can('run_script_actions_dry')) {
        $self->{last_error} = 'bot cannot run script actions dry';
        return undef;
    }

    my $raw_command = _ctx_command($ctx);

    # mb284-B1: command_allowed(), script_for_command(), and _command_is_scoped()
    # already classify commands through _normalize_command_name().  Use that same
    # canonical token for the rest of the observer flow too, so external
    # Perl/Python/Tcl scripts receive the command that was actually authorized
    # instead of raw context text such as "  !PyHello  ".
    my $command = _normalize_command_name($raw_command);
    unless ($self->command_allowed($command)) {
        my $name = length($command) ? $command : '<empty>';
        $self->{filtered_public}++;
        $self->{skipped_public}++;
        $self->{last_error} = "command '$name' not allowed by ScriptDryRun filter";
        return undef;
    }

    # mb263-B1: observe_public_command must pass the current command to
    # apply_scope_warning().  MB262 correctly made the guard command-aware,
    # but the runtime call site still invoked it without arguments, causing
    # valid scoped apply commands to be rejected in the real observer path.
    if (my $scope_warning = $self->apply_scope_warning($command)) {
        $self->{skipped_public}++;
        $self->{last_error} = $scope_warning;
        return undef;
    }

    my $script_path = $self->script_for_command($command);
    unless ($script_path) {
        my $name = defined($command) && length("$command") ? "$command" : '<empty>';
        $self->{skipped_public}++;
        $self->{last_error} = "no script configured for command '$name'";
        return undef;
    }

    # mb238-B1: do not mark a public command as handled in apply mode until
    # the apply runtime is actually available.  Without this guard, a bad boot
    # order or a partially initialized bot could swallow a routed script command
    # before ScriptRunner/ScriptActionRunner were ready, preventing the legacy
    # dispatcher from seeing the command and leaving the user with silence.
    if ($self->action_mode eq 'apply') {
        unless ($bot->can('script_runner') && $bot->script_runner && $bot->script_runner->can('run_script')) {
            $self->{skipped_public}++;
            $self->{last_error} = 'script runner is not initialized';
            return undef;
        }

        unless ($bot->can('script_action_runner') && $bot->script_action_runner && $bot->script_action_runner->can('apply_actions')) {
            $self->{skipped_public}++;
            $self->{last_error} = 'script action runner cannot apply actions';
            return undef;
        }
    }

    # mb196-B1: log before running the external script. If the script blocks or
    # times out, this line appears immediately and gives us the accepted route.
    $self->_log_bot(4, "PUBLIC(scriptdryrun): accepted command=$command script=$script_path mode=" . $self->action_mode . " allow_irc=" . $self->allow_irc);

    # mb194-B1 + A2 (mb225) + mb226-B1: ScriptDryRun runs the resolved script,
    # but OWNERSHIP (suppressing the legacy dispatcher) must stay consistent
    # with whether the run has side effects:
    #   - dry-run mode: only OWN explicitly scoped commands (ROUTES/COMMANDS).
    #     An unscoped bare-SCRIPT command still runs for observation but does
    #     not suppress the legacy "command not found" path (R1 footgun fix).
    #   - apply mode: if we are about to APPLY a script's IRC/log actions, we
    #     MUST also own the command. Otherwise an unscoped command would have
    #     its fallback script applied AND be dispatched by the legacy handler —
    #     a double execution (duplicate IRC). mb226-B1 closes that: in apply
    #     mode, running implies owning.
    my $owns_command = $self->_command_is_scoped($command)
        || ($self->action_mode eq 'apply');
    _ctx_mark_scriptdryrun_handled($ctx, undef, undef) if $owns_command;

    my %data = (
        channel => _ctx_scalar_value($ctx, 'channel', 'target'),
        target  => _ctx_scalar_value($ctx, 'target', 'channel'),
        nick    => _ctx_scalar_value($ctx, 'nick', 'sender'),
        command => $command,
        args    => _ctx_args($ctx),
    );

    my $result;
    my $run_started_at;
    my $apply_started_at;

    if ($self->action_mode eq 'apply') {
        $run_started_at = time();
        my $script_result = $bot->script_runner->run_script(
            $script_path,
            'public_command',
            %data,
        );

        $self->_log_script_result($command, $run_started_at, $script_result);

        my $context = {
            event   => 'public_command',
            channel => $data{channel},
            target  => $data{target},
            nick    => $data{nick},
            command => $data{command},
            args    => $data{args},
        };

        $apply_started_at = time();
        my $action_plan = $bot->script_action_runner->apply_actions(
            $script_result,
            $context,
            apply     => 1,
            allow_irc => $self->allow_irc,
        );

        $self->_log_action_plan($command, (defined $apply_started_at ? $apply_started_at : $run_started_at), $action_plan);

        $result = {
            ok            => ($script_result->{ok} && $action_plan->{applied_ok}) ? 1 : 0,
            dry_run       => 0,
            action_mode   => 'apply',
            allow_irc     => $self->allow_irc,
            script_result => $script_result,
            action_plan   => $action_plan,
        };
    }
    else {
        $run_started_at = time();
        $result = $bot->run_script_actions_dry(
            $script_path,
            'public_command',
            %data,
        );
        $result->{action_mode} = 'dry-run' if ref($result) eq 'HASH';
        $result->{allow_irc}   = $self->allow_irc if ref($result) eq 'HASH';

        my $script_result = ref($result) eq 'HASH' ? $result->{script_result} : undef;
        my $action_plan   = ref($result) eq 'HASH' ? $result->{action_plan}   : undef;
        $self->_log_script_result($command, $run_started_at, $script_result);
        $self->_log_action_plan($command, (defined $apply_started_at ? $apply_started_at : $run_started_at), $action_plan);
    }

    $self->{last_result} = $result;
    $self->{last_error}  = undef;
    _ctx_mark_scriptdryrun_handled($ctx, $result, undef) if $owns_command;

    return $result;
}

sub action_mode_raw {
    my ($self) = @_;
    return $self->{action_mode_raw};
}

sub action_mode {
    my ($self) = @_;
    return $self->{action_mode} || 'dry-run';
}

sub allow_irc {
    my ($self) = @_;
    return $self->{allow_irc} ? 1 : 0;
}


sub apply_require_scope {
    my ($self) = @_;
    return $self->{apply_require_scope} ? 1 : 0;
}

sub apply_scope_is_restricted {
    my ($self) = @_;
    return ($self->command_filter_enabled || $self->command_routes_enabled) ? 1 : 0;
}

sub apply_scope_warning {
    my ($self, $command) = @_;

    return undef unless $self->action_mode eq 'apply';
    return undef unless $self->apply_require_scope;

    # mb262-B1: APPLY_REQUIRE_SCOPE must protect the CURRENT command, not only
    # the global plugin configuration.  A config such as ROUTES=foo=... plus a
    # fallback SCRIPT is globally "restricted", but command "bar" is still not
    # explicitly scoped.  In apply mode that fallback would apply script actions
    # for an unscoped command.  Reject it unless the operator explicitly opts out
    # with APPLY_REQUIRE_SCOPE=no.
    return undef if defined($command) && $self->_command_is_scoped($command);

    return 'ACTION_MODE=apply requires COMMANDS or ROUTES matching the current command when APPLY_REQUIRE_SCOPE is enabled';
}


sub command_routes_raw {
    my ($self) = @_;
    return $self->{command_routes_raw};
}

sub command_routes {
    my ($self) = @_;
    return $self->{command_routes} || {};
}

sub command_route_list {
    my ($self) = @_;
    return sort keys %{ $self->command_routes };
}

sub command_routes_enabled {
    my ($self) = @_;
    return scalar(keys %{ $self->command_routes }) ? 1 : 0;
}

sub script_for_command {
    my ($self, $command) = @_;

    my $normalized = _normalize_command_name($command);
    my $routes = $self->command_routes;

    return $routes->{$normalized} if length($normalized) && exists $routes->{$normalized};
    return $self->{script_path};
}


sub command_filter_raw {
    my ($self) = @_;
    return $self->{command_filter_raw};
}

sub command_filter {
    my ($self) = @_;
    return $self->{command_filter} || {};
}

sub command_filter_list {
    my ($self) = @_;
    return sort keys %{ $self->command_filter };
}

sub command_filter_enabled {
    my ($self) = @_;
    return scalar(keys %{ $self->command_filter }) ? 1 : 0;
}

sub command_allowed {
    my ($self, $command) = @_;

    my $filter = $self->command_filter;
    my $routes = $self->command_routes;

    my $normalized = _normalize_command_name($command);
    return 0 unless length $normalized;

    # mb184-F1: routes-only mode must not allow every command.
    # If COMMANDS exists, it is an allow-list.
    # If ROUTES exists without SCRIPT fallback, routed commands only are allowed.
    # If SCRIPT fallback exists and no COMMANDS filter exists, every command can
    # fall back to SCRIPT unless a route overrides it.
    return 1 if $routes->{$normalized};
    return 1 if $filter->{$normalized};

    if (scalar keys %$filter) {
        return 0;
    }

    if (scalar keys %$routes) {
        return defined($self->{script_path}) && length("$self->{script_path}") ? 1 : 0;
    }

    return defined($self->{script_path}) && length("$self->{script_path}") ? 1 : 0;
}

# A2 (mb225): true only when the command is explicitly scoped (routed or in the
# COMMANDS allow-list). Used to decide whether ScriptDryRun OWNS the public
# command (i.e. suppresses the legacy dispatcher). A bare SCRIPT fallback alone
# never owns a command.
sub _command_is_scoped {
    my ($self, $command) = @_;

    my $normalized = _normalize_command_name($command);
    return 0 unless length $normalized;

    return 1 if $self->command_routes->{$normalized};
    return 1 if $self->command_filter->{$normalized};
    return 0;
}


sub script_path {
    my ($self) = @_;
    return $self->{script_path};
}

sub observed_public {
    my ($self) = @_;
    return $self->{observed_public} || 0;
}

sub skipped_public {
    my ($self) = @_;
    return $self->{skipped_public} || 0;
}

sub filtered_public {
    my ($self) = @_;
    return $self->{filtered_public} || 0;
}


sub last_result {
    my ($self) = @_;
    return $self->{last_result};
}

sub last_error {
    my ($self) = @_;
    return $self->{last_error};
}

1;
