#!/usr/local/cpanel/3rdparty/bin/perl

#                                      Copyright 2026 WebPros International, LLC
#                                                           All rights reserved.
# copyright@cpanel.net                                         http://cpanel.net
# This code is subject to the cPanel license. Unauthorized copying is prohibited.

package scripts::backfill_nova_feature_lists;

use cPstrict;

=encoding utf-8

=head1 NAME

backfill_nova_feature_lists

=head1 SYNOPSIS

    backfill_nova_feature_lists [--help]

=head1 DESCRIPTION

This script backfills the C<standalone-nova> feature's default features
into every feature list where C<standalone-nova> is already enabled, not
just the plugin's own default list. This keeps existing Nova feature
lists in sync whenever new defaults are added to the feature's metadata
(the C<dependencies> entry in C<install.json>).

The defaults are read from the C<standalone-nova> feature metadata
rather than being duplicated here, so they cannot drift from
C<install.json>.

All of a feature list's new defaults are applied in a single write.
Each write re-validates the entire feature list against its
C<allow_list> and C<only_one_rules>, so writing one feature at a time
can fail validation partway through a list that is missing several
defaults and never converge.

This script runs once per C<DEFAULTS_VERSION>. When new defaults are
added to C<install.json>, bump C<DEFAULTS_VERSION> so existing servers
re-run the backfill. The run-once lock is only marked complete once
every Nova feature list is inspected and written successfully; if any
list cannot be inspected, loaded, or validated, the lock is left unset
so the next upgrade retries it.

Features disabled server-wide (present in the C<disabled> list) are
never written, since that server-wide state takes precedence over any
individual feature list and enabling them here would silently override
the administrator's choice.

Each run re-applies the feature's I<entire> current dependency list, not
just the defaults added since the previous C<DEFAULTS_VERSION>. Bumping
C<DEFAULTS_VERSION> again therefore re-enables every entry in that list
that is not disabled server-wide, including any that an administrator
may have since disabled within an individual feature list.

=head1 OPTIONS

=over 4

=item B<--help>, B<-h>

Show this help message.

=back

=head1 EXIT STATUS

Always exits 0; per-feature-list failures are logged and retried on the
next upgrade rather than failing the upgrade step.

=head1 SEE ALSO

L<Common::Plugins::Features>, L<Cpanel::Features>

=cut

use Common::Plugins::Features    ();
use Cpanel::Features             ();
use Cpanel::Features::Load       ();
use Cpanel::FileUtils::TouchFile ();
use Cpanel::Imports;
use Getopt::Long ();

use constant DEFAULTS_VERSION => '2.5.0';
use constant LOCK_DIR         => '/var/cpanel/version';

run() if !caller;

=head2 run()

Entry point. Loads the C<standalone-nova> defaults from feature
metadata, then backfills them into every feature list where
C<standalone-nova> is enabled, guarded by a run-once lock keyed on
C<DEFAULTS_VERSION>.

=head3 RETURNS

0 on a normal run. Returns 1, as a logged no-op, when the feature
metadata cannot be loaded (C<install_plugin> must run first to publish
it). Per-feature-list write failures never cause this to return
non-zero; they are logged and left for the next upgrade to retry.

=cut

sub run() {
    Getopt::Long::GetOptions( 'help|h' => sub { print_usage(); exit(0); } )
      or do { print_usage(); exit(1); };

    my $metadata = eval { Common::Plugins::Features::get_feature_metadata('standalone-nova') };

    if ( $@ || !$metadata ) {
        logger()->warn("Unable to load feature metadata for standalone-nova; skipping backfill: $@");
        return 1;
    }

    my @defaults = @{ $metadata->dependencies // [] };

    _do_once( 'backfill_nova_feature_lists_v' . DEFAULTS_VERSION, sub { return _backfill( \@defaults ); } );

    return 0;
}

=head2 _backfill($defaults)

Applies every default feature in C<$defaults> that is not disabled
server-wide to every feature list where C<standalone-nova> is enabled,
in a single validated write per list. The C<default> template list is
included when it is a real feature list on the server.

=head3 ARGUMENTS

=over 4

=item C<$defaults> - ArrayRef - Feature names to enable in each list.
Any feature disabled server-wide (in the C<disabled> list) is skipped.

=back

=head3 RETURNS

Boolean - true only if every Nova feature list was inspected and
written successfully. A list that cannot be inspected during discovery,
fails to load, or fails validation is logged and skipped, but causes
the overall result to be false. Returns true when no feature list has
C<standalone-nova> enabled.

=cut

sub _backfill($defaults) {

    # Skip the pseudo-lists that get_feature_lists() returns alongside real
    # user feature lists: 'disabled' is the server-wide off-switch, and
    # 'default'/'Mail Only' are built-in templates, none of which are plans we
    # should backfill. This matches how core's Cpanel::Features::modify_feature
    # sweeps feature lists.
    my @feature_lists =
      grep { !/^(?:disabled|default|Mail Only)$/ } Cpanel::Features::get_feature_lists();

    # The 'default' list is the template new accounts inherit, so keep it in
    # sync too when it is a real feature list on this server.
    push @feature_lists, 'default' if Cpanel::Features::Load::is_feature_list('default');

    # Server-wide disabled features (value '0' in the 'disabled' list) take
    # precedence over any individual feature list. Writing '=1' for one here
    # would persist a latent auto-enable that overrides the administrator's
    # choice the moment they re-enable it server-wide, so drop those from the
    # defaults before writing.
    my %disabled = Cpanel::Features::Load::load_featurelist( 'disabled', '=' );
    my @available_defaults =
      grep { !( exists $disabled{$_} && ( $disabled{$_} // '' ) eq '0' ) } @{$defaults};

    my $all_succeeded = 1;

    foreach my $list (@feature_lists) {

        # Discover membership here rather than via
        # get_featurelists_with_feature_pattern(): that helper silently drops
        # any list it cannot load, which would let this run report success and
        # set the run-once lock while a Nova list never received the new
        # defaults. Counting an inspection failure as an overall failure keeps
        # the lock unset so the next upgrade retries.
        my $effective = eval { Common::Plugins::Features::load_effective_featurelist($list) };
        if ( $@ || ref $effective ne 'HASH' ) {
            logger()->warn("Unable to inspect feature list '$list' while backfilling Nova defaults: $@");
            $all_succeeded = 0;
            next;
        }

        next if ( $effective->{'standalone-nova'} // '' ) ne '1';

        my $features = eval { Cpanel::Features::Load::load_featurelist($list) };
        if ( $@ || !$features ) {
            logger()->warn("Unable to load feature list '$list' while backfilling Nova defaults: $@");
            $all_succeeded = 0;
            next;
        }

        $features->{$_} = '1' for @available_defaults;

        eval { Cpanel::Features::update_featurelist( $list, $features, 'root', 1 ); };
        if ($@) {
            logger()->warn("Skipping '$list' while backfilling Nova defaults: $@");
            $all_succeeded = 0;
            next;
        }
    }

    return $all_succeeded;
}

=head2 _do_once($flag, $code)

Run-once guard. Runs C<$code> only if the lock named C<$flag> does not
already exist under C<LOCK_DIR>. The lock is created only once
C<$code> returns a true value, so a failing or dying run leaves the
lock unset and retries on the next invocation.

=head3 ARGUMENTS

=over 4

=item C<$flag> - String - Lock name, appended to C<cpanel> under
C<LOCK_DIR>.

=item C<$code> - CodeRef - The work to perform once.

=back

=head3 RETURNS

The return value of C<$code>, or 1 if the lock already existed.

=cut

sub _do_once ( $flag, $code ) {
    my $lock = LOCK_DIR . '/cpanel' . $flag;

    return 1 if -e $lock;

    my $result = eval { $code->() };
    if ($@) {
        logger()->warn("Error while backfilling Nova default features: $@");
        return $result;
    }

    return $result if !$result;

    Cpanel::FileUtils::TouchFile::touchfile($lock);

    return $result;
}

=head2 print_usage()

Print command-line usage information.

=cut

sub print_usage() {
    say "Usage: $0 [--help]";
    say "";
    say "Backfills the standalone-nova feature's default features into every";
    say "feature list where standalone-nova is already enabled.";
    return;
}

1;
