# Copyright (C) 2019 Felix Lechner <felix.lechner@lease-up.com>
#
# 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 of the License, 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, you can find it on the World Wide
# Web at https://www.gnu.org/copyleft/gpl.html, or write to the Free
# Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston,
# MA 02110-1301, USA.

package Lintian::Changelog;

use v5.20;
use warnings;
use utf8;

use Carp;
use Const::Fast;
use Date::Parse;

use Lintian::Changelog::Entry;

const my $EMPTY => q{};
const my $SPACE => q{ };
const my $ASTERISK => q{*};
const my $UNKNOWN => q{unknown};

use Moo;
use namespace::clean;

=head1 NAME

Lintian::Changelog -- Parse a literal version string into its constituents

=head1 SYNOPSIS

 use Lintian::Changelog;

 my $version = Lintian::Changelog->new;
 $version->set('1.2.3-4', undef);

=head1 DESCRIPTION

A class for parsing literal version strings

=head1 CLASS METHODS

=over 4

=item new ()

Creates a new Lintian::Changelog object.

=cut

=item find_closes

Takes one string as argument and finds "Closes: #123456, #654321" statements
as supported by the Debian Archive software in it. Returns all closed bug
numbers in an array reference.

=cut

sub find_closes {
    my $changes = shift;
    my @closes = ();

    while (
        $changes
        && ($changes
            =~ /(closes:\s*(?:bug)?\#?\s?\d+(?:,\s*(?:bug)?\#?\s?\d+)*)/ig)
    ) {
        push(@closes, $1 =~ /\#?\s?(\d+)/g);
    }

    @closes = sort { $a <=> $b } @closes;
    return \@closes;
}

=back

=head1 INSTANCE METHODS

=over 4

=item parse (STRING)

Parses STRING as the content of a debian/changelog file.

=cut

sub parse {
    my ($self, $contents) = @_;

    $self->errors([]);
    $self->entries([]);

    # careful with negative matching /m
    unless (
        $contents =~ m{^ \S+ \s* [(] [^\)]+ [)] \s* (?:[^ \t;]+ \s*)+ ; }mx) {

        push(@{$self->errors}, [1, 'not a Debian changelog']);
        return;
    }

    my @lines = split(/\n/, $contents);

    # based on /usr/lib/dpkg/parsechangelog/debian
    my $expect='first heading';
    my $entry = Lintian::Changelog::Entry->new;
    my $blanklines = 0;

    # to make unknown version unique, for id
    my $unknown_version_counter = 1;

    my $position = 1;
    for my $line (@lines) {

        # trim end
        $line =~ s/\s+\r?$//;

      #	print encode_utf*(sprintf(STDERR "%-39.39s %-39.39s\n",$expect,$line));
        if ($line
            =~ m/^(?<Source>\w[-+0-9a-z.]*) \((?<Version>[^\(\) \t]+)\)(?<Distribution>(?:\s+[-+0-9a-z.]+)+)\;\s*(?<kvpairs>.*)$/i
        ){
            my $source = $+{Source};
            my $version = $+{Version};
            my $distribution = $+{Distribution};
            my $kvpairs = $+{kvpairs};

            unless ($expect eq 'first heading'
                || $expect eq 'next heading or eof') {
                $entry->ERROR(
                    [
                        $position,
                        "found start of entry where expected $expect",$line
                    ]
                );
                push @{$self->errors}, $entry->ERROR;
            }

            unless ($entry->is_empty) {
                $entry->Closes(find_closes($entry->Changes));

                push @{$self->entries}, $entry;
                $entry = Lintian::Changelog::Entry->new;
            }

            $entry->position($position);

            $entry->Header($line);

            $entry->Source($source);
            $entry->Version($version);

            $distribution =~ s/^\s+//;
            $entry->Distribution($distribution);

            my %kvdone;
            for my $kv (split(/\s*,\s*/,$kvpairs)) {
                $kv =~ m/^([-0-9a-z]+)\=\s*(.*\S)$/i
                  ||push @{$self->errors},
                  [$position,"bad key-value after ';': '$kv'"];
                my $k = ucfirst $1;
                my $v = $2;
                $kvdone{$k}++
                  && push @{$self->errors},
                  [$position,"repeated key-value $k"];
                if ($k eq 'Urgency') {
                    $v =~ m/^([-0-9a-z]+)((\s+.*)?)$/i
                      ||push @{$self->errors},
                      [$position,"badly formatted urgency value $v"];
                    $entry->Urgency($1);
                    $entry->Urgency_LC(lc($1));
                    $entry->Urgency_Comment($2);
                } elsif ($k =~ m/^X[BCS]+-/i) {
                    # Extensions - XB for putting in Binary,
                    # XC for putting in Control, XS for putting in Source
                    $entry->{$k}= $v;
                } elsif ($k ne 'Binary-only') {
                    push @{$self->errors},
                      [$position,
                        "unknown key-value key $k - copying to XS-$k"];
                    $entry->{ExtraFields}{"XS-$k"} = $v;
                }
            }
            $expect= 'start of change data';
            $blanklines = 0;

        } elsif ($line =~ /^(?:;;\s*)?Local variables:/i) {
            last; # skip Emacs variables at end of file

        } elsif ($line =~ /^vim:/i) {
            last; # skip vim variables at end of file

        } elsif ($line =~ /^\$\w+:.*\$/) {
            next; # skip stuff that look like a CVS keyword

        } elsif ($line =~ /^\# /) {
            next; # skip comments, even that's not supported

        } elsif ($line =~ m{^/\*.*\*/}) {
            next; # more comments

        } elsif ($line
            =~ m/^(?:\w+\s+\w+\s+\d{1,2} \d{1,2}:\d{1,2}:\d{1,2}\s+[\w\s]*\d{4})\s+(?:.*)\s+[<\(](?:.*)[\)>]/
            || $line
            =~ m/^(?:\w+\s+\w+\s+\d{1,2},?\s*\d{4})\s+(?:.*)\s+[<\(](?:.*)[\)>]/
            || $line =~ m/^(?:\w[-+0-9a-z.]*) \((?:[^\(\) \t]+)\)\;?/i
            || $line =~ m/^(?:[\w.+-]+)[- ]\S+ Debian \S+/i
            || $line =~ m/^Changes from version (?:.*) to (?:.*):/i
            || $line =~ m/^Changes for [\w.+-]+-[\w.+-]+:?$/i
            || fc($line) eq fc('Old Changelog:')
            || $line =~ m/^(?:\d+:)?\w[\w.+~-]*:?$/) {
            # save entries on old changelog format verbatim
            # we assume the rest of the file will be in old format once we
            # hit it for the first time
            last;

        } elsif ($line =~ m/^\S/) {
            push @{$self->errors},
              [$position,'badly formatted heading line', $line];

        } elsif ($line
            =~ m/^ \-\- (?<name>.*) <(?<email>.*)>(?<sep>  ?)(?<date>(?:\w+\,\s*)?\d{1,2}\s+\w+\s+\d{4}\s+\d{1,2}:\d\d:\d\d\s+[-+]\d{4}(?:\s+\([^\\\(\)]\))?)$/
        ) {

            my $name = $+{name};
            my $email = $+{email};
            my $separator = $+{sep};
            my $date = $+{date};

            $expect eq 'more change data or trailer'
              || push @{$self->errors},
              [$position,"found trailer where expected $expect", $line];
            if ($separator ne $SPACE . $SPACE) {
                push @{$self->errors},
                  [$position,'badly formatted trailer line', $line];
            }
            $entry->Trailer($line);
            $entry->Maintainer("$name <$email>")
              unless length $entry->Maintainer;

            unless(length $entry->Date && defined $entry->Timestamp) {
                $entry->Date($date);
                $entry->Timestamp(str2time($date));
                unless (defined $entry->Timestamp) {
                    push @{$self->errors},
                      [$position,"could not parse date $date"];
                }
            }
            $expect = 'next heading or eof';

        } elsif ($line =~ m/^ \-\-/) {
            $entry->{ERROR}
              = [$position, 'badly formatted trailer line', $line];
            push @{$self->errors}, $entry->ERROR;
            #	    $expect = 'next heading or eof'
            #		if $expect eq 'more change data or trailer';

        } elsif ($line =~ m/^\s{2,}(\S)/) {
            $expect eq 'start of change data'
              || $expect eq 'more change data or trailer'
              || do {
                push @{$self->errors},
                  [$position,"found change data where expected $expect",$line];
                if (($expect eq 'next heading or eof')
                    && !$entry->is_empty) {
                    # lets assume we have missed the actual header line
                    $entry->Closes(find_closes($entry->Changes));

                    push @{$self->entries}, $entry;

                    $entry = Lintian::Changelog::Entry->new;
                    $entry->Source($UNKNOWN);
                    $entry->Distribution($UNKNOWN);
                    $entry->Urgency($UNKNOWN);
                    $entry->Urgency_LC($UNKNOWN);
                    $entry->Version($UNKNOWN . (++$unknown_version_counter));
                    $entry->Urgency_Comment($EMPTY);
                    $entry->ERROR(
                        [
                            $position,
                            "found change data where expected $expect",$line
                        ]
                    );
                }
              };
            $entry->{'Changes'} .= (" \n" x $blanklines)." $line\n";
            if (!$entry->{Items} || $1 eq $ASTERISK) {
                $entry->{Items} ||= [];
                push @{$entry->{Items}}, "$line\n";
            } else {
                $entry->{'Items'}[-1] .= (" \n" x $blanklines)." $line\n";
            }
            $blanklines = 0;
            $expect = 'more change data or trailer';

        } elsif ($line !~ m/\S/) {
            next
              if $expect eq 'start of change data'
              || $expect eq 'next heading or eof';
            $expect eq 'more change data or trailer'
              || push @{$self->errors},
              [$position,"found blank line where expected $expect"];
            $blanklines++;

        } else {
            push @{$self->errors}, [$position, 'unrecognised line', $line];
            (        $expect eq 'start of change data'
                  || $expect eq 'more change data or trailer')
              && do {
                # lets assume change data if we expected it
                $entry->{'Changes'} .= (" \n" x $blanklines)." $line\n";
                if (!$entry->{Items}) {
                    $entry->{Items} ||= [];
                    push @{$entry->{Items}}, "$line\n";
                } else {
                    $entry->{'Items'}[-1] .= (" \n" x $blanklines)." $line\n";
                }
                $blanklines = 0;
                $expect = 'more change data or trailer';
                $entry->ERROR([$position, 'unrecognised line', $line]);
              };
        }

    } continue {
        ++$position;
    }

    $expect eq 'next heading or eof'
      || do {
        $entry->ERROR([$position, "found eof where expected $expect"]);
        push @{$self->errors}, $entry->ERROR;
      };

    unless ($entry->is_empty) {
        $entry->Closes(find_closes($entry->Changes));
        push @{$self->entries}, $entry;
    }

    return;
}

=item errors

=item entries

=cut

has errors => (is => 'rw', default => sub { [] });
has entries => (is => 'rw', default => sub { [] });

=back

=head1 AUTHOR

Originally written by Felix Lechner <felix.lechner@lease-up.com> for Lintian.

=head1 SEE ALSO

lintian(1)

=cut

1;

# Local Variables:
# indent-tabs-mode: nil
# cperl-indent-level: 4
# End:
# vim: syntax=perl sw=4 sts=4 sr et
