#!/usr/bin/env perl

use strict;
use warnings;

use File::Temp qw(tempfile);
use FindBin qw($Dir);
use Getopt::Long;
use File::Basename;
use File::Copy;
use Cwd qw(realpath);
use Pod::Usage;

use constant {
  UNSET => bless({},"UNSET"),
};

sub slurpfile {
  my ($file) = @_;

  open(my $fh, "<", $file)
    or die "Cannot open file '$file'\n";
  my $content = do { local $/ = undef; <$fh> };
  close($fh);

  return $content;
}

sub dumpfile {
  my ($file, $content) = @_;

  open(my $fh, ">", $file)
    or die "Cannot open file '$file'\n";
  print $fh $content;
  close($fh);
}

sub read_kconfig {
  my ($string) = @_;

  my @lines;
  my %options;
  my @order;

  foreach my $line (split /\n/,$string)
    {
      chomp $line;

      $line =~ s/^\s+|\s+$//g;
      push @lines, $line;
      my $line_idx = @lines - 1;

      if ($line =~ /^# (.+) is not set$/)
        {
          $options{$1} = { value => UNSET, idx => $line_idx };
          push @order, $1;
        }
      elsif ($line =~ /^([^=]+)=(.*)$/)
        {
          $options{$1} = { value => $2, idx => $line_idx };
          push @order, $1;
        }
      elsif ($line =~ /^\s*(#.*)?$/)
        {
          # Ignore
        }
      else
        {
          die "Can't parse line '$line'\n";
        }
    }

  return {
    options => \%options,
    lines => \@lines,
    order => \@order,
  };
}

sub write_kconfig
{
  my ($config) = @_;
  return join "", map { "$_\n" } grep { defined $_ } @{$config->{lines}};
}

sub match_migration
{
  my ($config, $match) = @_;

  foreach my $key (@{$match->{order}})
    {
      return 0 unless exists($config->{options}{$key});

      my $config_value = $config->{options}{$key}{value};
      my $match_value = $match->{options}{$key}{value};

      my $config_is_unset = ref($config_value) eq "UNSET";
      my $match_is_unset = ref($match_value) eq "UNSET";

      if ($config_is_unset || $match_is_unset)
        {
          return 0 unless $config_is_unset && $match_is_unset;
        }
      else
        {
          return 0 unless $config_value eq $match_value;
        }
    }

  return 1;
}

sub apply_migration
{
  my ($config, $match, $replace) = @_;

  foreach my $key (@{$match->{order}})
    {
      # Do not remove if we replace it anyway.
      next if exists $replace->{options}{$key};

      # Remove line
      splice @{$config->{lines}}, $config->{options}{$key}{idx}, 1, undef;

      # Remove parsed option
      delete $config->{options}{$key};

      # Don't update order, doesn't matter for $config
    }

  foreach my $key (@{$replace->{order}})
    {
      my $updated_line = $replace->{lines}->[$replace->{options}{$key}{idx}];
      my $line_idx;
      # Option exists already, overwrite in-place
      if (exists($config->{options}{$key}))
        {
          # Replace line
          splice @{$config->{lines}}, $config->{options}{$key}{idx}, 1,
            $updated_line;

          $line_idx = $config->{options}{$key}{idx};
        }
      else
        {
          # Option didn't exist yet. Append line to bottom
          push @{$config->{lines}}, $updated_line;

          $line_idx = @{$config->{lines}} - 1;
        }

      $config->{options}{$key} = {
        value => $replace->{options}{$key}{value},
        idx => $line_idx,
      };
    }
}

my $yes;
my $interactive = -t STDIN;
my $no_keep_orig;
my $migrationfile = "$Dir/migrate_kconfig.settings";
my $help = 0;

sub confirm_prompt
{
  my ($prompt) = @_;

  print "$prompt (y/n)? ";

  if ($interactive && !$yes)
    {
      my $answer = <STDIN>;
      chomp $answer;

      return lc($answer) eq "y";
    }
  else
    {
      print (($yes ? "y" : "n") . "\n");
      return $yes;
    }
}

Getopt::Long::Configure("bundling");
GetOptions(
  "y|yes" => \$yes,
  "i|interactive" => sub { $interactive = 1; },
  "no-interactive" => sub { $interactive = 0; },
  "no-keep-orig" => \$no_keep_orig,
  "m|migration=s" => \$migrationfile,
  "h|help" => sub { $help++; },
) or pod2usage(1);

pod2usage(-verbose => $help) if $help;

my $auto_confirm = $yes;
$auto_confirm //= 0 unless $interactive;

my ($configfile) = @ARGV;

pod2usage("Missing config file") unless defined($configfile);
pod2usage("Cannot read config file '$configfile'") unless -r $configfile;

# Load migration config
my $migrations = do (realpath($migrationfile));

my $configcontent = slurpfile($configfile);
my $config = read_kconfig($configcontent);

my $anychanges = 0;

my $spec_idx = 0;
foreach my $spec (@$migrations)
  {
    die "Invalid migration (Index $spec_idx)"
      unless defined($spec->{match}) && defined($spec->{replace});
    $spec_idx++;

    my $match = read_kconfig($spec->{match});
    my $replace = read_kconfig($spec->{replace});
    my $prompt = $spec->{prompt};

    if (match_migration($config, $match))
      {
        if (!defined($prompt) || confirm_prompt($prompt))
          {
            apply_migration($config, $match, $replace);
            $anychanges = 1;
          }
      }
  }

unless ($anychanges)
  {
    print "Nothing to migrate.\n";
    exit(0);
  }

my ($tmpfh, $tempfile) = tempfile(
  basename($configfile) . ".XXXXXXX",
  TMPDIR => 1
);
print $tmpfh write_kconfig($config);
close($tmpfh);

system("diff",
       $interactive ? "--color=always" : (),
       "--unified",
       $configfile,
       $tempfile);
my $exit_code = $? >> 8;

print "\n";

if ($exit_code == 0) # Diff tool says nothing changed. Nothing to do.
  {
    print "Migrations applied, nothing changed.\n";
    exit(0);
  }
elsif ($exit_code != 1) # Exit code 1 means there's a difference. Not an error.
  {
    die "Diff tool exited with exit code $exit_code. Aborting.\n";
  }

if ($interactive)
  {
    # Disable auto-confirm from now on.
    $yes = undef;

    unless (confirm_prompt("Apply diff"))
      {
        print "Aborted.\n";
        exit(1);
      }
  }

if (!$no_keep_orig)
  {
    # Save original as backup
    print "Saving original content to ${configfile}.orig\n";
    copy("$configfile", "${configfile}.orig") or
      die "Unable to save backup '${configfile}.orig'. Aborting.\n";
  }

copy($tempfile, $configfile) or
  die "Unable to write file '$configfile'\n";

print "$configfile migrated.\n";

__END__

=head1 NAME

migrate_kconfig - Match and apply migrations to configuration file

=head1 SYNOPSIS

migrate_kconfig [options] <globalconfig.out>

=head1 OPTIONS

=over

=item -y, --yes

Assume yes as answer for all confirmations whether or not a specific migration
shall be applied to the configuration. If --no-interactive is specified also
assume yes for the final confirmation.

=item --no-interactive

Do not ask for confirmation including the final confirmation question. Unless
--yes is specified, assume no as answer for all questions including the final
confirmation.

This option is implied if STDIN is not a TTY.

Overrides any previous B<--interactive>

=item --interactive

Ask for confirmation even if STDIN is not a TTY.

Overrides any previous B<--no-interactive>.

=item --no-keep-orig

By default if this tool alters the specified configuration file, it creates a
copy with the original content under the same file name plus a suffix C<.orig>.

Specifying this option skips the creation of this copy.

=item -m, --migration <file>

Specifies which file to load migrations from. Defaults to
C<migrate_kconfig.settings> in the same directory as this script.

See section MIGRATIONS in full help text (-hh) for more information.

=item -h, --help

Show short help text.

=item -hh, --help --help

Show full help text.

=back

=head1 DESCRIPTION

This tool loads a list of migrations from a migration file, matches each
migration to the configuration file and applies each matching migration (in
specific cases after confirmation) to said configuration file. Once all
migrations are applied the user is present with the textual diff between the
original and the migration configuration file.

=head1 MIGRATIONS

=head2 MIGRATIONS FILE FORMAT

The migrations file is a Perl script. The last statement must provide an
arrayref of migrations to apply. Migrations are matched and applied in order,
with each migration being matched and applied before the next migration is
matched and applied.

=head2 MIGRATION

Each migration is represented by a hashref containing a key C<match>, a key
C<replace> and a optionally a key C<prompt>:

The keys C<match> as well as C<replace> contain a multiline string in the same
format as a configuration file (i.e. globalconfig.out). The order of options
within the C<match> string is irrelevant. Leading whitespaces in each line of
the string are ignored, so that these options can be indented for better
readability.

migrate_kconfig matches each option in C<match> with the corresponding option in
the configuration file. If all options in the key C<match> have the same value
as in the configuration file, these options are removed and all the options
specified in the key C<replace> are inserted/updated. The same option may be
specified in C<match> and C<replace> in order to let the migration change the
value of said option (or keep the option if the value is identical in both
C<match> and C<replace>).

C<match> may be empty to insert/update the options in C<replace> without
condition. C<replace> may be empty to simply remove all options in C<match>.

In addition to the keys C<match> and C<replace> a migration may have a key
C<prompt> with a string as value. This string will be used for interactive
confirmation, and should be a statement describing the migration, e.g. "Enable
x", "Disable y", "Convert from A to B", "Increase Z".

=head3 EXAMPLE

[

  ...

  {
    prompt => "Update max core count to 32 for platform arm_virt if MP is enabled",
    match => q(
      CONFIG_PF_ARM_VIRT=y
      CONFIG_MP=y
    ),
    replace => q(
      CONFIG_PF_ARM_VIRT=y
      CONFIG_MP=y
      CONFIG_MP_MAX_CPUS=32
    ),
  },

  ...

]

=head2 OPTION COMPARISON

During the matching of two options the values are compared.

=over

=item *

If the option is absent on either side, the option is consider not matching.

=item *

If at least one of the values is denoted as "is not set" comment, then both
values are only considered matching if both values are denoted as "is not
set" comment

=item *

In all other cases, both values are string compared, including any quotation.

=back

=head2 OPTION INSERTION/UPDATE

The migrate_kconfig tool retains the order of the options in the original
configuration file. If an option is present in the configuration file as well as
the key C<replace> of a migration the option is updated in the same line. If an
option is present in the key C<replace>, but not in the configuration file then
the option is appended to the end of the file.
