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

package scripts::verify_wptk_userdata;

#                                      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.

=encoding utf-8

=head1 NAME

verify_wptk_userdata - Verify WP Toolkit userdata against WPTK instance data

=head1 SYNOPSIS

    scripts/verify_wptk_userdata [OPTIONS]

    Options:
        --help               Display this help message
        --silent-check       No output, exit code indicates status (mutually exclusive with --repair)
        --repair             Make changes to fix discrepancies (mutually exclusive with --silent-check)
        --only-add-update    Only add and update wptkids, do not remove
        --user=USERNAME      Verify only the specified user (can be specified multiple times, mutually exclusive with --skip-user)
        --skip-user=USERNAME Skip the specified user (can be specified multiple times, mutually exclusive with --user)

=head1 DESCRIPTION

This script verifies WP Toolkit data in userdata against the WPTK instance data.

By default, this script reports discrepancies but does not make any changes.
Use the C<--repair> option to apply fixes.

When using the C<--only-add-update> option, the script will only add missing
WPTK IDs and update mismatched IDs, but will not remove any WPTK IDs from
userdata.

Use the C<--silent-check> option for scripted checks - the exit code will be
non-zero if any discrepancies are found.

=head1 EXIT CODES

=over 4

=item 0 - No discrepancies found

=item 1 - Discrepancies were found

=back

=cut

use cPstrict;

use parent qw( Cpanel::HelpfulScript );

use Cpanel::Config::Users              ();
use Cpanel::WPTK::Util::VerifyUserdata ();

use Try::Tiny;

use constant _OPTIONS => (
    'silent-check',
    'repair',
    'only-add-update',
    'user=s@',
    'skip-user=s@',
);

use constant EXIT_NO_PROBLEMS    => 0;
use constant EXIT_PROBLEMS_FOUND => 1;

if ( !caller ) {
    exit( __PACKAGE__->new(@ARGV)->run() );
}

sub run ($self) {

    $self->ensure_root();

    my $options = $self->_parse_and_validate_options();

    $self->_print_startup_message( $options->{repair} );

    my @users    = $self->_get_users_to_verify($options);
    my $verifier = $self->_create_verifier($options);
    my $result   = $self->_verify_all_users( $verifier, \@users, $options->{silent_check} );

    $self->_print_final_report( $result, $options ) unless $options->{silent_check};

    my $has_problems = keys $result->{users}->%* || scalar $result->{errors}->@*;
    return $has_problems ? EXIT_PROBLEMS_FOUND : EXIT_NO_PROBLEMS;
}

sub _parse_and_validate_options ($self) {
    my $silent_check    = $self->getopt('silent-check');
    my $repair          = $self->getopt('repair');
    my $only_add_update = $self->getopt('only-add-update');
    my $user_opt        = $self->getopt('user')      // [];
    my $skip_user_opt   = $self->getopt('skip-user') // [];

    if ( $silent_check && $repair ) {
        die $self->help("--silent-check and --repair are mutually exclusive options.\n");
    }

    if ( $user_opt->@* && $skip_user_opt->@* ) {
        die $self->help("--user and --skip-user are mutually exclusive options.\n");
    }

    return {
        silent_check    => $silent_check,
        repair          => $repair,
        only_add_update => $only_add_update,
        user_opt        => $user_opt,
        skip_user_opt   => $skip_user_opt,
    };
}

sub _print_startup_message ( $self, $repair ) {
    $self->_out('Starting verify_wptk_userdata...');

    if ( !$repair ) {
        $self->_out('Verification mode (read-only). Use --repair to apply fixes.');
    }

    return;
}

sub _get_users_to_verify ( $self, $options ) {
    return $options->{user_opt}->@* if $options->{user_opt}->@*;

    my %skip_users = map { $_ => 1 } $options->{skip_user_opt}->@*;
    return sort grep { !$skip_users{$_} } Cpanel::Config::Users::getcpusers();
}

sub _create_verifier ( $self, $options ) {
    return Cpanel::WPTK::Util::VerifyUserdata->new(
        output          => $options->{silent_check}    ? undef : $self->get_output_object(),
        quiet           => $options->{silent_check}    ? 1     : 0,
        repair          => $options->{repair}          ? 1     : 0,
        only_add_update => $options->{only_add_update} ? 1     : 0,
    );
}

sub _verify_all_users ( $self, $verifier, $users, $silent_check ) {
    my $result = {
        users  => {},
        errors => [],
    };

    for my $username ( $users->@* ) {
        my $user_result = $self->_verify_single_user( $verifier, $username, $result, $silent_check );

        next unless $self->_has_issues($user_result);

        $result->{users}{$username} = $user_result;
        $self->_out('-');
    }

    return $result;
}

sub _verify_single_user ( $self, $verifier, $username, $result, $silent_check ) {
    my $user_result;

    try {
        $user_result = $verifier->verify_wptk_userdata($username);
    }
    catch {
        my $err = $_;
        my $msg = "Error processing user '$username': $err";
        push $result->{errors}->@*, $msg;
        $self->_warn($msg) if !$silent_check;
    };

    return $user_result;
}

sub _has_issues ( $self, $user_result ) {
    return 0 unless defined $user_result;
    return grep { ref $_ eq 'HASH' && keys $_->%* } values $user_result->%*;
}

sub _print_final_report ( $self, $result, $options ) {
    $self->_report_results($result);

    if ( $options->{repair} ) {
        $self->_out('Repair completed.');
    }
    else {
        $self->_out('Verification (read-only) completed. Use --repair to apply fixes.');
    }

    return;
}

sub _report_results ( $self, $result ) {

    my $users  = $result->{users};
    my $errors = $result->{errors} // [];

    if ( !keys $users->%* && !scalar $errors->@* ) {
        $self->_out('No actionable discrepancies found between WPTK and userdata.');
        return;
    }

    $self->_print_summary_header();

    my %totals = $self->_report_user_discrepancies($users);
    $self->_report_errors($errors);
    $self->_print_totals( \%totals, $errors );

    return;
}

sub _print_summary_header ($self) {
    $self->_out('');
    $self->_out('====================');
    $self->_out('Summary');
    $self->_out('====================');
    return;
}

sub _report_user_discrepancies ( $self, $users ) {
    my %totals = (
        updated   => 0,
        added     => 0,
        removed   => 0,
        bad_owner => 0,
        stale     => 0,
    );

    for my $username ( sort keys $users->%* ) {
        my $user_data = $users->{$username};

        $self->_out("User: $username");

        my $user_indent = $self->_indent_guard();

        $self->_report_discrepancy_type( 'updated',   $user_data->{updated},   \%totals );
        $self->_report_discrepancy_type( 'added',     $user_data->{added},     \%totals );
        $self->_report_discrepancy_type( 'removed',   $user_data->{removed},   \%totals );
        $self->_report_discrepancy_type( 'stale',     $user_data->{stale},     \%totals );
        $self->_report_discrepancy_type( 'bad_owner', $user_data->{bad_owner}, \%totals );
    }

    return %totals;
}

sub _report_discrepancy_type ( $self, $type, $data, $totals ) {
    for my $domain ( sort keys $data->%* ) {
        my $message = $self->_format_discrepancy_message( $type, $domain, $data->{$domain} );
        $self->_out($message);
        $totals->{$type}++;
    }
    return;
}

sub _format_discrepancy_message ( $self, $type, $domain, $data ) {
    if ( $type eq 'updated' ) {
        return "[UPDATE] Mismatched in userdata (id: $data->{old}) and in WPTK (id: $data->{new}): $domain";
    }
    elsif ( $type eq 'added' ) {
        return "[ADD] Missing in userdata and exists in WPTK (id: $data): $domain";
    }
    elsif ( $type eq 'removed' ) {
        return "[REMOVE] Exists in userdata (id: $data) and missing in WPTK: $domain";
    }
    elsif ( $type eq 'stale' ) {
        return "[MANUAL FIX] Exists in userdata and WPTK (id: $data) without domain userdata, requires manual cleanup: $domain";
    }
    elsif ( $type eq 'bad_owner' ) {
        return "[REMOVE] Exists in userdata (id: $data->{id}) and exists in WPTK for a different owner ($data->{owner}): $domain";
    }
    return;
}

sub _report_errors ( $self, $errors ) {
    return unless scalar $errors->@*;

    $self->_out('');
    $self->_out('Errors encountered:');

    my $errors_indent = $self->_indent_guard();
    for my $error ( $errors->@* ) {
        $self->_out($error);
    }

    return;
}

sub _print_totals ( $self, $totals, $errors ) {
    my $total = $self->_calculate_total($totals);

    $self->_out('');
    $self->_out("Total discrepancies: $total");

    {
        my $totals_indent = $self->_indent_guard();

        $self->_out("Update:    $totals->{updated}");
        $self->_out("Add:       $totals->{added}");
        $self->_out("Remove:    $totals->{removed}");
        $self->_out("Bad owner: $totals->{bad_owner}");
        $self->_out("Stale:     $totals->{stale} (manual fix needed)");
        $self->_out( "Errors:    " . scalar $errors->@* );
    }

    $self->_out('====================');
    $self->_out('');

    return;
}

sub _calculate_total ( $self, $totals ) {
    my $total = 0;
    foreach my $value ( values $totals->%* ) {
        $total += $value;
    }
    return $total;
}

sub _out ( $self, $message ) {
    return if $self->getopt('silent-check');
    $self->get_output_object()->out($message);
    return;
}

sub _warn ( $self, $message ) {
    return if $self->getopt('silent-check');
    $self->get_output_object()->warn($message);
    return;
}

sub _indent_guard ($self) {
    return $self->get_output_object()->create_indent_guard();
}

1;
