#!/usr/bin/perl

# Copyright (C) 2018-2024 Mageia
#                         Martin Whitaker <mageia@martin-whitaker.me.uk>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2, or (at your option)
# any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1335, USA.

use strict;
use warnings;

use Glib qw(TRUE FALSE);
use Gtk3 '-init';
use Locale::TextDomain qw(qarepo);
use MDK::Common;
use URPM;
use utf8;

my $version = 'v1.8';

binmode(STDOUT, ":utf8");

###############################################################################
# States and Status
###############################################################################

my %status_text = (
    disabled => N("Disabled"),
    enabled  => N("Enabled"),
    changed  => N("Needs update"),
    failed   => N("Update failed")
);

my $state;

###############################################################################
# Initial Configuration
###############################################################################

my $home = $ENV{HOME} || './';

# Only use pkexec if not run by root.
my $pkexec = $> ? 'pkexec' : '';

my %config;

# Settings are stored in the config file in key=value format.
my $config_file = "$home/.qareporc";
if (open(my $f, '<', $config_file)) {
    while (my $line = <$f>) {
        chomp($line);
        my ($key, $value) = split(/=/, $line);
        $config{$key} = $value if $key;
    }
    close($f);
}

# Use sensible defaults for settings not in the config file.
my $mirror  = $config{MIRROR}  // 'rsync://mirrors.kernel.org/mirrors/mageia';
my $release = $config{RELEASE} // '9';
my $arch    = $config{ARCH}    // 'x86_64';
my $nonfree = $config{NONFREE} // 1;
my $tainted = $config{TAINTED} // 1;
my $qa_repo = $config{QA_REPO} // "$home/qa-testing";

my %qa_repo_names = (
    i586   => 'QA Testing (32-bit)',
    x86_64 => 'QA Testing (64-bit)'
);

my $qa_repo_name;
my $active_qa_repo;

my $last_release = $release;
my $last_arch    = '';

my $fatal_message = N("*** application will terminate ***");
my $fatal_error;

###############################################################################
# GUI Main Window
###############################################################################

my $window  = Gtk3::Window->new('toplevel');

my $grid    = Gtk3::Grid->new();

my $label1  = Gtk3::Label->new(N("Mirror:"));
my $entry1  = Gtk3::Entry->new();

my $label2  = Gtk3::Label->new(N("Release:"));
my $entry2  = Gtk3::Entry->new();

my $label3  = Gtk3::Label->new(N("QA Repo:"));
my $entry3  = Gtk3::Entry->new();

# PO: abbreviation of "architecture", e.g. i586, x86_64
my $label4  = Gtk3::Label->new(N("Arch:"));
my $entry4  = Gtk3::ComboBoxText->new();

my $label5  = Gtk3::Label->new(N("RPMs:"));
my $entry5  = Gtk3::TextView->new();

my $scroll  = Gtk3::ScrolledWindow->new();

my $label6  = Gtk3::Label->new(N("Status:"));
my $status  = Gtk3::Label->new('');

my $button1 = Gtk3::Button->new(N("Quit"));
my $button2 = Gtk3::Button->new(N("Disable"));
my $button3 = Gtk3::Button->new(N("Enable"));
my $button4 = Gtk3::Button->new(N("Clear"));
my $button5 = Gtk3::Button->new(N("Downgrade"));

my $check0  = Gtk3::CheckButton->new_with_label("core");
my $check1  = Gtk3::CheckButton->new_with_label("nonfree");
my $check2  = Gtk3::CheckButton->new_with_label("tainted");

my $check3  = Gtk3::CheckButton->new_with_label(N("fuzzy\nversion"));
# PO: abbreviation of "add dependencies"
my $check4  = Gtk3::CheckButton->new_with_label(N("add\ndeps"));

$window->set_title("QA Repo  $version");
$window->set_default_size(600, 400);
$window->set_border_width(10);
$window->signal_connect(delete_event => \&quit);

$grid->set_row_spacing(10);
$grid->set_column_spacing(10);

$label1->set_halign('GTK_ALIGN_END');

$entry1->set_text($mirror);
$entry1->set_hexpand(TRUE);
$entry1->signal_connect(changed => \&changed);

$label2->set_halign('GTK_ALIGN_END');

$entry2->set_text($release);
$entry2->set_width_chars(2);
$entry2->set_hexpand(TRUE);
$entry2->signal_connect(changed => \&changed);

$label3->set_halign('GTK_ALIGN_END');

$entry3->set_text($qa_repo);
$entry3->set_hexpand(TRUE);
$entry3->signal_connect(changed => \&changed);

$label4->set_halign('GTK_ALIGN_END');

$entry4->append_text('i586');
$entry4->append_text('x86_64');
if ($arch eq 'x86_64') {
    $entry4->set_active(1);
} else {
    $entry4->set_active(0);
}
$entry4->signal_connect(changed => \&changed);

$label5->set_valign('GTK_ALIGN_START');
$label5->set_halign('GTK_ALIGN_END');

$entry5->get_buffer->signal_connect(changed => \&changed);

$scroll->set_hexpand(TRUE);
$scroll->set_vexpand(TRUE);
$scroll->add($entry5);

$label6->set_halign('GTK_ALIGN_END');

$status->set_halign('GTK_ALIGN_START');

$button1->signal_connect(clicked => \&quit);

$button2->signal_connect(clicked => \&disable);

$button3->signal_connect(clicked => \&enable);

$button4->set_vexpand(TRUE);
$button4->set_valign('GTK_ALIGN_END');
$button4->signal_connect(clicked => \&clear);

$button5->signal_connect(clicked => \&downgrade);

$check0->set_active(TRUE);
$check0->set_sensitive(FALSE);

$check1->set_active($nonfree);
$check1->signal_connect(clicked => \&changed);

$check2->set_active($tainted);
$check2->signal_connect(clicked => \&changed);

$check3->set_active(FALSE);
$check3->signal_connect(clicked => \&changed);

$check4->set_active(FALSE);
$check4->signal_connect(clicked => \&changed);

$grid->attach($label1, 0, 0, 1, 1);
$grid->attach($entry1, 1, 0, 5, 1);

$grid->attach($label2, 1, 1, 1, 1);
$grid->attach($entry2, 2, 1, 1, 1);
$grid->attach($check0, 3, 1, 1, 1);
$grid->attach($check1, 4, 1, 1, 1);
$grid->attach($check2, 5, 1, 1, 1);

$grid->attach($label3, 0, 2, 1, 1);
$grid->attach($entry3, 1, 2, 4, 1);
$grid->attach($entry4, 5, 2, 1, 1);
$grid->attach($label5, 0, 3, 1, 1);
$grid->attach($scroll, 1, 3, 5, 5);
$grid->attach($label6, 0, 8, 1, 1);
$grid->attach($status, 1, 8, 5, 1);

$grid->attach($button1, 7, 0, 1, 1);
$grid->attach($button2, 7, 2, 1, 1);
$grid->attach($button3, 7, 3, 1, 1);
$grid->attach($check3,  7, 4, 1, 1);
$grid->attach($check4,  7, 5, 1, 1);
$grid->attach($button4, 7, 6, 1, 1);
$grid->attach($button5, 7, 7, 1, 1);

$window->add($grid);

###############################################################################
# GUI Dialogue Window
###############################################################################

my $dialogue_window = Gtk3::Window->new('toplevel');

my $dialogue_grid   = Gtk3::Grid->new();

my $dialogue_label  = Gtk3::Label->new();

my $dialogue_text   = Gtk3::TextView->new();

my $dialogue_scroll = Gtk3::ScrolledWindow->new();

my $dialogue_button = Gtk3::Button->new(N("Dismiss"));

$dialogue_window->set_default_size(600, 300);
$dialogue_window->set_border_width(10);
$dialogue_window->set_type_hint('dialog');
$dialogue_window->signal_connect(delete_event => \&dialogue_dismiss);

$dialogue_grid->set_row_spacing(10);
$dialogue_grid->set_column_spacing(10);

$dialogue_label->set_halign('GTK_ALIGN_START');

$dialogue_text->set_editable(FALSE);

$dialogue_scroll->set_hexpand(TRUE);
$dialogue_scroll->set_vexpand(TRUE);
$dialogue_scroll->add($dialogue_text);

$dialogue_button->signal_connect(clicked => \&dialogue_dismiss);
$dialogue_button->set_halign('GTK_ALIGN_CENTER');

$dialogue_grid->attach($dialogue_label,  0, 0, 1, 1);
$dialogue_grid->attach($dialogue_scroll, 0, 1, 1, 1);
$dialogue_grid->attach($dialogue_button, 0, 2, 1, 1);

$dialogue_window->add($dialogue_grid);

###############################################################################
# GUI Start
###############################################################################

changed();

$window->show_all();

Gtk3->main();

###############################################################################
# GUI Callbacks
###############################################################################

sub changed {
    $arch = trim($entry4->get_active_text());
    if ($arch ne $last_arch) {
        $last_arch = $arch;
        set_qa_repo_info();
        if ($active_qa_repo) {
            set_state('enabled');
        } else {
            set_state('disabled');
        }
    } else {
        set_state('changed');
    }
}

sub quit {
    get_settings();
    if (open(my $f, '>', $config_file)) {
        printf $f "MIRROR=%s\n",  $mirror;
        printf $f "RELEASE=%s\n", $release;
        printf $f "ARCH=%s\n",    $arch;
        printf $f "NONFREE=%d\n", $nonfree;
        printf $f "TAINTED=%d\n", $tainted;
        printf $f "QA_REPO=%s\n", $qa_repo;
        close($f);
    }
    Gtk3->main_quit();
}

sub disable {
    disable_buttons();
    disable_repo();
    set_state('disabled');
}

sub enable {
    check_no_testing_media(N("This may enable unwanted packages to be installed."))
      or return;
    disable_buttons();
    get_settings();
    if (sync_repo()) {
        if ($active_qa_repo) {
            update_repo();
        } else {
            enable_repo();
        }
    } else {
        if ($active_qa_repo) {
            disable_repo();
        }
    }
    if ($active_qa_repo) {
        set_state('enabled');
    } else {
        set_state('failed');
    }
}

sub clear {
    $entry5->get_buffer()->set_text('');
}

sub downgrade {
    check_no_testing_media(N("This may stop some packages from being downgraded."))
      or return;
    disable_buttons();
    if ($active_qa_repo) {
        disable_repo();
    }
    downgrade_packages();
    set_state('disabled');
}

sub dialogue_dismiss {
    if ($fatal_error) {
        Gtk3->main_quit();
    } else {
        $dialogue_window->hide_on_delete()
    }
}

###############################################################################
# Subsidiary Functions
###############################################################################

sub check_no_testing_media {
    my ($message2) = @_;
    if (system("urpmq --list-media active --list-url | grep -q updates_testing") == 0) {
        my $message1 = N("Some updates_testing media are enabled.");
        my $message3 = N("Please disable these media and try again.");
        show_error_dialogue(($message1, $message2, $message3));
        return 0;
    }
    1;
}

sub set_qa_repo_info {
    $qa_repo_name = $qa_repo_names{$arch};

    my $repo_name_and_url = `urpmq --list-url | grep '$qa_repo_name '`;
    chomp($repo_name_and_url);
    $repo_name_and_url =~ s#file://##;

    $active_qa_repo = $repo_name_and_url =~ s/\Q$qa_repo_name\E\s+(\S+)\/$arch/$1/r;

    if ($repo_name_and_url && $active_qa_repo ne $qa_repo) {
        disable_repo();
    }

    $entry5->get_buffer->set_text(join("\n", get_existing_rpms()));
}

sub set_state {
    my ($new_state) = @_;
    $state = $new_state;
    $status->set_label($status_text{$state});
    $button1->set_sensitive(TRUE);
    $button2->set_sensitive($active_qa_repo);
    $button3->set_sensitive($state ne 'enabled');
    $button4->set_sensitive(TRUE);
    $button5->set_sensitive($state ne 'changed');
    if ($state eq 'changed' || $state eq 'failed') {
        $button3->set_label(N("Update"));
    } else {
        $button3->set_label(N("Enable"));
    }
}

sub disable_buttons {
    $button1->set_sensitive(FALSE);
    $button2->set_sensitive(FALSE);
    $button3->set_sensitive(FALSE);
    $button4->set_sensitive(FALSE);
    $button5->set_sensitive(FALSE);
    gtk_update();
}

sub get_settings {
    $mirror  = trim($entry1->get_text());
    $release = trim($entry2->get_text());
    $arch    = trim($entry4->get_active_text());
    $nonfree = $check1->get_active();
    $tainted = $check2->get_active();
    $qa_repo = trim($entry3->get_text());
    if ($active_qa_repo && $active_qa_repo ne $qa_repo) {
        disable_repo();
    }
}

sub get_requested_rpms {
    my $buffer = $entry5->get_buffer();
    my $start  = $buffer->get_start_iter();
    my $end    = $buffer->get_end_iter();

    my $fuzzy_version = $check3->get_active();

    my @lines = split("\n", $buffer->get_text($start, $end, FALSE));
    if ($fuzzy_version) {
        # replace version-release with wildcard
        s/-\d.*-.+(\.mga$release(?:(?:\.$arch|\.noarch)(?:\.rpm)?)?)$/-\\d*$1/ foreach @lines;
    }
    s/^\s+// foreach @lines;  # trim leading white space
    s/\s+$// foreach @lines;  # trim trailing white space
    grep { $_ ne '' } @lines; # and discard blank lines
}

sub get_existing_rpms {
    map { basename($_) } glob("$qa_repo/$arch/*.rpm");
}

sub disable_repo {
    my $arch_type = $arch eq 'x86_64' ? '64' : '32';
    if (system("$pkexec /usr/libexec/qarepo-helper disable $arch_type") == 0) {
        $active_qa_repo = '';
    } else {
        # PO: preserve %s - it is replaced by the name of the repository
        my $message = sprintf(N("couldn't disable the '%s' local repository"), $qa_repo_name);
        show_error_dialogue($message, $fatal_message);
        print_error($message, 'fatal');
    }
}

sub enable_repo {
    my $arch_type = $arch eq 'x86_64' ? '64' : '32';
    if (system("$pkexec /usr/libexec/qarepo-helper enable $arch_type $qa_repo/$arch") == 0) {
        $active_qa_repo = $qa_repo;
    } else {
        # PO: preserve %s - it is replaced by the name of the repository
        my $message = sprintf(N("couldn't enable the '%s' local repository"), $qa_repo_name);
        show_error_dialogue($message);
        print_error($message);
        $active_qa_repo = '';
    }
}

sub update_repo {
    my $arch_type = $arch eq 'x86_64' ? '64' : '32';
    if (system("$pkexec /usr/libexec/qarepo-helper update $arch_type") != 0) {
        # PO: preserve %s - it is replaced by the name of the repository
        my $message = sprintf(N("couldn't update the '%s' local repository"), $qa_repo_name);
        show_error_dialogue($message);
        print_error($message);
        disable_repo();
    }
}

sub clear_repo {
    my ($type) = @_;
    my @existing_rpms = grep { $_ =~ /$type/ } get_existing_rpms();
    if (@existing_rpms) {
        if (!unlink(map { "$qa_repo/$arch/$_" } @existing_rpms)) {
            my $message = N("couldn't delete existing RPMs in the local repository");
            show_error_dialogue($message, $fatal_message);
            print_error($message, 'fatal');
        }
    }
}

my @sync_errors;

sub sync_repo {
    $status->set_label(N("Updating"));
    @sync_errors = ();

    my $sync_file;
    if      ($mirror =~ /^rsync:/) {
        $sync_file = \&sync_file_rsync;
    } elsif ($mirror =~ /^ftp:/ || $mirror =~ /^https?:/) {
        $sync_file = \&sync_file_aria2;
    } elsif ($mirror !~ /^\w+:/) {
        $sync_file = \&sync_file_link;
    } else {
        my $message = N("unsupported mirror URL type");
        show_error_dialogue($message);
        print_error($message);
        return 0;
    }

    if ($release ne $last_release) {
        $last_release = $release;
        clear_repo();
        gtk_update();
    }

    my $add_dependencies = $check4->get_active();

    my $remote_repo = "$mirror/distrib/$release/$arch/media";
    my $local_repo  = "$qa_repo/$arch";

    my @mediatypes = ( 'core' );
    push @mediatypes, 'nonfree' if $nonfree;
    push @mediatypes, 'tainted' if $tainted;

    my $download_dir = "$qa_repo/.download";
    mkdir_p($download_dir);

    my %rpm_dependencies;
    foreach my $media_type (@mediatypes) {
        my $synthesis = 'synthesis.hdlist.cz';
        my $remote_dir = "$remote_repo/$media_type/updates_testing/media_info";
        &$sync_file("$remote_dir/$synthesis", $download_dir) or next;
        gtk_update();

        my $urpm = new URPM;
        $urpm->parse_synthesis("$download_dir/$synthesis");
        $urpm->traverse(sub {
            my ($pkg) = @_;
            my $name = $pkg->fullname();
            my $rpm = "$name.rpm";
            %{$rpm_dependencies{$rpm}} = ();
            if ($add_dependencies) {
                my @requires = ( $pkg->requires_nosense(), $pkg->recommends_nosense );
                $urpm->traverse_tag('whatprovides', \@requires, sub {
                    my ($pkg) = @_;
                    my $name = $pkg->fullname();
                    ${$rpm_dependencies{$rpm}}{"$name.rpm"} = 1;
                });
            }
        });

        if (!unlink("$download_dir/$synthesis")) {
            # PO: preserve %s - it is replaced by the name of the file
            my $message = sprintf(N("couldn't delete the downloaded synthesis file '%s'"), $download_dir . '/' . $synthesis);
            show_error_dialogue($message, $fatal_message);
            print_error($message, 'fatal');
        }
        gtk_update();
    }

    my %selection;
    my @requests = get_requested_rpms();
    while (@requests) {
        foreach my $request (@requests) {
            my $pattern = wildcard_to_regexp($request);
            my $matched = 0;
            foreach my $candidate (keys %rpm_dependencies) {
                if ($candidate =~ /^($pattern)((\.($arch|noarch))?\.rpm)?$/) {
                    $selection{$candidate} = 1;
                    $selection{$_} ||= 2 foreach keys %{$rpm_dependencies{$candidate}};
                    $matched = 1;
                }
            }
            # PO: preserve %s - it is replaced by the name of a package
            $matched or sync_error(sprintf(N("'%s' was not found in the remote repository"), $request));
        }
        # avoid infinite loop if we haven't found a match
        last if @sync_errors;
        # recurse through any new dependencies
        @requests = grep { $selection{$_} == 2 } keys %selection;
    }

    if (@sync_errors) {
        show_error_dialogue(@sync_errors);
        return 0;
    }

    my @required_rpms = sort keys %selection;
    my @existing_rpms = get_existing_rpms();
    my @unwanted_rpms = difference2(\@existing_rpms, \@required_rpms);
    if (@unwanted_rpms) {
        if (!unlink(map { "$local_repo/$_" } @unwanted_rpms)) {
            my $message = "couldn't delete unwanted RPMs in the local repository";
            show_error_dialogue($message, $fatal_message);
            print_error($message, 'fatal');
        }
    }
    my $old_pubkey = "$local_repo/media_info/pubkey";
    if (-e $old_pubkey) {
        if (!unlink($old_pubkey)) {
            my $message = N("couldn't delete the old pubkey in the local repository");
            show_error_dialogue($message, $fatal_message);
            print_error($message, 'fatal');
        }
    }

    mkdir_p("$local_repo/media_info");
    gtk_update();

    foreach my $rpm (difference2(\@required_rpms, \@existing_rpms)) {
        my $remote_url = $remote_repo;
        if ($rpm =~ /tainted/) {
            $remote_url .= "/tainted/updates_testing/$rpm";
        } elsif ($rpm =~ /nonfree/) {
            $remote_url .= "/nonfree/updates_testing/$rpm";
        } else {
            $remote_url .= "/core/updates_testing/$rpm";
        }
        &$sync_file($remote_url, $local_repo);
        gtk_update();
    }
    &$sync_file("$remote_repo/core/updates_testing/media_info/pubkey", "$local_repo/media_info");
    gtk_update();

    if (@sync_errors) {
        print_error(N("failed to download all the files"));
    } else {
        system("genhdlist2 --allow-empty-media $local_repo") == 0
          or sync_error(N("failed to update hdlist"));
    }

    if (@sync_errors) {
        show_error_dialogue(@sync_errors);
        return 0;
    }

    1;
}

sub sync_file_rsync {
    my ($src_url, $dst_dir) = @_;
    # PO: preserve %s - it is replaced by the URL of the file being downloaded
    print sprintf(N("fetching %s"), $src_url), "\n";
    system("rsync -q $src_url $dst_dir") == 0
      # PO: preserve %s - it is replaced by the URL of the file
      or sync_error(sprintf(N("failed to download file '%s'"), $src_url));
}

sub sync_file_aria2 {
    my ($src_url, $dst_dir) = @_;
    # PO: preserve %s - it is replaced by the URL of the file being downloaded
    print sprintf(N("fetching %s"), $src_url), "\n";
    system("aria2c -q -d $dst_dir $src_url") == 0
      and return 1;

    # aria2c leaves empty or partially downloaded files.
    my $dst_file = $dst_dir . '/' . basename($src_url);
    unlink($dst_file) if -e $dst_file;

    # PO: preserve %s - it is replaced by the URL of the file
    sync_error(sprintf(N("failed to download file '%s'"), $src_url));
}

sub sync_file_link {
    my ($src_file, $dst_dir) = @_;
    -e $src_file && symlink($src_file, $dst_dir . '/' . basename($src_file))
      # PO: preserve %s - it is replaced by the URL of the file
      or sync_error(sprintf(N("failed to link to file '%s'"), $src_file));
}

sub sync_error {
    my ($message) = @_;
    push @sync_errors, $message;
    print_error($message);
    0;
}

sub downgrade_packages {
    my $synthesis = "$qa_repo/$arch/media_info/synthesis.hdlist.cz";
    if (! -e $synthesis) {
        my $message = N("no synthesis file found in the local repository");
        show_error_dialogue($message);
        print_error($message);
        return 0;
    }

    my @packages;
    my $urpm = new URPM;
    $urpm->parse_synthesis($synthesis);
    $urpm->traverse(sub {
        my ($pkg) = @_;
        my $full_name = $pkg->fullname;
        if (system("rpm --quiet -q $full_name") == 0) {
            push @packages, $pkg->name();
        }
    });

    if (@packages) {
        @packages = sort @packages;
        show_downgrade_dialogue("urpmi --downgrade @packages");
    } else {
        show_error_dialogue(N("none of the listed packages are installed"));
    }
}

sub trim {
    my ($text) = @_;
    $text =~ s/^\s+//;
    $text =~ s/\s+$//;
    $text;
}

sub wildcard_to_regexp {
    my ($pattern) = @_;
    $pattern =~ s/\./\\./g;
    $pattern =~ s/\+/\\+/g;
    $pattern =~ s/\*/.*/g;
    $pattern =~ s/\?/./g;
    $pattern;
}

sub show_downgrade_dialogue {
    $dialogue_window->set_title(N("Downgrade"));
    $dialogue_label->set_text(N("The following command may be used to downgrade the listed packages:"));
    $dialogue_text->get_buffer()->set_text(join("\n", @_));
    $dialogue_text->set_wrap_mode('GTK_WRAP_WORD_CHAR');
    $dialogue_window->show_all();
}

sub show_error_dialogue {
    $dialogue_window->set_title(N("Error"));
    $dialogue_label->set_text(N("The following error(s) occurred:"));
    $dialogue_text->get_buffer()->set_text(join("\n", @_));
    $dialogue_text->set_wrap_mode('GTK_WRAP_NONE');
    $dialogue_window->show_all();
}

sub print_error {
    my ($message, $o_fatal) = @_;
    print N("ERROR:"), " ", $message, ".\n";
    $fatal_error = $o_fatal;
}

sub gtk_update {
    while (Gtk3::events_pending()) {
        Gtk3::main_iteration();
    }
}

# Use gettext to translate the text. Assume the returned string is UTF-8.
sub N {
    my ($text) = @_;
    my $str = __($text);
    utf8::decode($str);
    $str;
}
