# Copyright 2011, 2012 Kevin Ryde

# This file is part of Math-NumSeq.
#
# Math-NumSeq 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 3, or (at your option) any later
# version.
#
# Math-NumSeq 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 Math-NumSeq.  If not, see <http://www.gnu.org/licenses/>.


# http://oeis.org/wiki/Clear-cut_examples_of_keywords
#

package Math::NumSeq::OEIS::File;
use 5.004;
use strict;
use Carp;
use POSIX ();
use File::Spec;
use Symbol 'gensym';
use Math::NumSeq;

use vars '$VERSION','@ISA';
$VERSION = 36;

use Math::NumSeq;
@ISA = ('Math::NumSeq');
*_bigint = \&Math::NumSeq::_bigint;

use vars '$VERSION';
$VERSION = 36;

eval q{use Scalar::Util 'weaken'; 1}
  || eval q{sub weaken { $_[0] = undef }; 1 }
  || die "Oops, error making a weaken() fallback: $@";

# uncomment this to run the ### lines
#use Smart::Comments;


# use constant name => Math::NumSeq::__('OEIS File');
use Math::NumSeq::OEIS;
*parameter_info_array = \&Math::NumSeq::OEIS::parameter_info_array;

sub description {
  my ($class_or_self) = @_;
  if (ref $class_or_self && defined $class_or_self->{'description'}) {
    return $class_or_self->{'description'};
  } else {
    return Math::NumSeq::__('OEIS sequence from file.');
  }
}

sub values_min {
  my ($self) = @_;
  ### OEIS-File values_min() ...
  return _analyze($self)->{'values_min'};
}
sub values_max {
  my ($self) = @_;
  ### OEIS-File values_max() ...
  return _analyze($self)->{'values_max'};
}

my %analyze_characteristics = (increasing            => 1,
                               increasing_from_i     => 1,
                               non_decreasing        => 1,
                               non_decreasing_from_i => 1,
                               smaller               => 1,
                              );
sub characteristic {
  my ($self, $key) = @_;
  if ($analyze_characteristics{$key}) {
    _analyze($self);
  }
  return shift->SUPER::characteristic(@_);
}

my $max_value = POSIX::FLT_RADIX() ** (POSIX::DBL_MANT_DIG()-5);
if ((~0 >> 1) > $max_value) {
  $max_value = (~0 >> 1);
}
my $min_value = -$max_value;

sub oeis_dir {
  require File::HomeDir;
  return File::Spec->catfile (File::HomeDir->my_home, 'OEIS');
}
sub anum_to_bfile {
  my ($anum, $prefix) = @_;
  $prefix ||= 'b';
  $anum =~ s/^A/$prefix/;
  return "$anum.txt";
}

my %instances;
sub DESTROY {
  my ($self) = @_;
  delete $instances{$self+0};
}
sub CLONE {
  my ($class) = @_;
  foreach my $self (values %instances) {
    next unless $self;
    next unless $self->{'fh'};
    my $pos = _tell($self);
    my $fh = gensym;
    if (open $fh, "< $self->{'filename'}") {
      $self->{'fh'} = $fh;
      _seek ($self, $pos);
    } else {
      delete $self->{'fh'};
      delete $self->{'filename'};
    }
  }
}


# special case a000000.txt files to exclude
#
my %afile_exclude
  = (
     # a003849.txt has replication level words rather than the individual
     # sequence values.
     'a003849.txt' => 1,

     # a027750.txt is unflattened divisors as lists.
     # Its first line is a correct looking "1 1" so _afile_is_good() doesn't
     # notice.
     'a027750.txt' => 1,
    );

sub new {
  ### OEIS-File new() ...
  my $self = shift->SUPER::new(@_);

  my $anum = $self->{'anum'};
  my $oeis_dir = oeis_dir();
  (my $num = $anum) =~ s/^A//;
  foreach my $basefile ("a$num.txt",
                        "b$num.txt") {
    next if $afile_exclude{$basefile};

    my $filename = File::Spec->catfile ($oeis_dir, $basefile);
    ### $filename

    my $fh = gensym;
    if (! open $fh, "< $filename") {
      ### cannot open: $!
      next;
    }

    $self->{'filename'} = $filename;
    $self->{'fh'} = $fh;
    if (! _afile_is_good($self)) {
      close delete $self->{'fh'};
      delete $self->{'filename'};
      next;
    }

    ### opened: $fh
    last;
  }

  my $have_info = (_read_internal($self, $anum)
                   || _read_html($self, $anum));

  if (! $have_info && ! $self->{'fh'}) {
    croak 'B-file, Internal or HTML not found for A-number "',$anum,'"';
  }

  weaken($instances{$self+0} = $self);
  return $self;
}

sub _analyze {
  my ($self) = @_;

  if ($self->{'analyze_done'}) {
    return $self;
  }
  $self->{'analyze_done'} = 1;

  ### _analyze() ...

  my $i_start = $self->i_start;
  my ($i, $value);
  my ($prev_i, $prev_value);

  my $values_min;
  my $values_max;
  my $increasing_from_i = $i_start;
  my $non_decreasing_from_i = $i_start;
  my $strictly_smaller_count = 0;
  my $smaller_count = 0;
  my $total_count = 0;

  my $analyze = sub {
    ### $prev_value
    ### $value
    if (! defined $values_min || $value < $values_min) {
      $values_min = $value;
    }
    if (! defined $values_max || $value > $values_max) {
      $values_max = $value;
    }

    if (defined $prev_value) {
      my $cmp = ($value <=> $prev_value);
      if ($cmp < 0) {
        # value < $prev_value
        $increasing_from_i = $i;
        $non_decreasing_from_i = $i;
      }
      if ($cmp <= 0) {
        # value <= $prev_value
        $increasing_from_i = $i;
      }
    }

    $total_count++;
    $smaller_count += (abs($value) <= $i);
    $strictly_smaller_count += ($value < $i);

    $prev_i = $value;
    $prev_value = $value;
  };

  if (my $fh = $self->{'fh'}) {
    my $oldpos = _tell($self);
    while (($i, $value) = _readline($self)) {
      $analyze->($value);
      last if $total_count > 200;
    }
    _seek ($self, $oldpos);
  } else {
    $i = $i_start;
    foreach (@{$self->{'array'}}) {
      $i++;
      $value = $_;
      $analyze->();
    }
  }

  my $range_is_small = (defined $values_max
                        && $values_max - $values_min <= 16);
  ### $range_is_small

  # "full" means whole sequence in sample values
  # "sign" means negatives in sequence
  if (! defined $self->{'values_min'}
      && ($range_is_small
          || $self->{'characteristic'}->{'OEIS_full'}
          || ! $self->{'characteristic'}->{'OEIS_sign'})) {
    ### set values_min: $values_min
    $self->{'values_min'} = $values_min;
  }
  if (! defined $self->{'values_max'}
      && ($range_is_small
          || $self->{'characteristic'}->{'OEIS_full'})) {
    ### set values_max: $values_max
    $self->{'values_max'} = $values_max;
  }

  $self->{'characteristic'}->{'smaller'}
    = ($total_count == 0
       || ($smaller_count / $total_count >= .9
           && $strictly_smaller_count > 0));
  ### decide smaller: $self->{'characteristic'}->{'smaller'}

  ### $increasing_from_i
  if (defined $prev_i && $increasing_from_i < $prev_i) {
    if ($increasing_from_i - $i_start < 20) {
      $self->{'characteristic'}->{'increasing_from_i'} = $increasing_from_i;
    }
    if ($increasing_from_i == $i_start) {
      $self->{'characteristic'}->{'increasing'} = 1;
    }
  }

  ### $non_decreasing_from_i
  if (defined $prev_i && $non_decreasing_from_i < $prev_i) {
    if ($non_decreasing_from_i - $i_start < 20) {
      $self->{'characteristic'}->{'non_decreasing_from_i'} = $non_decreasing_from_i;
    }
    if ($non_decreasing_from_i == $i_start) {
      $self->{'characteristic'}->{'non_decreasing'} = 1;
    }
  }

  return $self;
}

# compare $x <=> $y but in strings in case they're bigger than IV or NV
# my $cmp = _value_cmp ($value, $prev_value);
sub _value_cmp {
  my ($x, $y) = @_;
  ### _value_cmp(): "$x  $y"
  ### cmp: $x cmp $y

  my $x_neg = substr($x,0,1) eq '-';
  my $y_neg = substr($y,0,1) eq '-';
  ### $x_neg
  ### $y_neg

  return ($y_neg <=> $x_neg
          || ($x_neg ? -1 : 1) * (length($x) <=> length($y)
                                  || $x cmp $y));
}

sub _seek {
  my ($self, $pos) = @_;
  seek ($self->{'fh'}, $pos, 0)
    or croak "Cannot seek $self->{'filename'}: $!";
}
sub _tell {
  my ($self) = @_;
  my $pos = tell $self->{'fh'};
  if ($pos < 0) {
    croak "Cannot tell file position $self->{'filename'}: $!";
  }
  return $pos;
}

sub rewind {
  my ($self, $pos) = @_;
  $self->{'i'} = $self->i_start;
  $self->{'array_pos'} = 0;
  if ($self->{'fh'}) {
    seek ($self->{'fh'}, 0, 0)
      or croak "Cannot seek $self->{'filename'}: $!";
  }
}
sub next {
  my ($self) = @_;

  my $value;
  if (my $fh = $self->{'fh'}) {
    ($self->{'i'}, $value) = _readline($self)
      or return;
  } else {
    my $array = $self->{'array'};
    my $pos = $self->{'array_pos'}++;
    if ($pos > $#$array) {
      return;
    }
    $value = $array->[$pos];
  }
  return ($self->{'i'}++, $value);
}

sub _readline {
  my ($self) = @_;
  my $fh = $self->{'fh'};
  while (defined (my $line = <$fh>)) {
    chomp $line;
    $line =~ tr/\r//d;    # delete CR if CRLF line endings, eg. b009000.txt
    ### $line

    if ($line =~ /^\s*(#|$)/) {
      ### ignore blank or comment ...
      # comment lines with "#" eg. b002182.txt
      next;
    }

    if (my ($i, $value) = ($line =~ /^([0-9]+)     # i
                                     [ \t]+
                                     (-?[0-9]+)    # value
                                     [ \t]*
                                     $/x)) {
      ### _readline: "$i  $value"
      if ($value > $max_value || $value < $min_value) {
        $value = _bigint()->new("$value");
      }
      return ($i, $value);
    }
  }
  return;
}

sub _afile_is_good {
  my ($self) = @_;
  my $fh = $self->{'fh'};
  while (defined (my $line = <$fh>)) {
    chomp $line;
    $line =~ tr/\r//d;    # delete CR if CRLF line endings, eg. b009000.txt
    ### $line

    if ($line =~ /^\s*(#|$)/) {
      ### ignore blank or comment ...
      next;
    }

    if ($line =~ /^([0-9]+)     # i
                  [ \t]+
                  (-?[0-9]+)    # value
                  [ \t]*
                  $/x) {
      _seek ($self, 0);
      return 1;
    } else {
      last;
    }
  }
  return 0;
}
sub _read_internal {
  my ($self, $anum) = @_;
  ### _read_internal(): $anum

  my $basefile = "$anum.internal";
  my $filename = File::Spec->catfile (oeis_dir(), $basefile);
  ### $basefile
  ### $filename

  if (! open FH, "<$filename") {
    ### cannot open .internal file: $!
    return 0;
  }
  my $contents = do { local $/; <FH> }; # slurp
  close FH or die "Error reading $filename: ",$!;

  # "Internal" files are served as html with a <meta> charset indicator
  $contents = _decode_html_charset($contents);

  my %characteristic = (integer => 1);
  $self->{'characteristic'} = \%characteristic;

  my $offset;
  if ($contents =~ /^%O\s+(\d+)/m) {
    $offset = $1;
  } else {
    $offset = 0;
  }

  my $description;
  if ($contents =~ m{^%N (.*?)(<tt>|$)}m) {
    $description = $1;
    $description =~ s/\s+/ /g;
    $description =~ s/<[^>]*?>//sg;  # <foo ...> tags
    $description =~ s/&lt;/</g;      # unentitize <
    $description =~ s/&gt;/>/g;      # unentitize >
    $description =~ s/&amp;/&/g;     # unentitize &
    $description =~ s/&#(\d+);/chr($1)/ge; # unentitize numeric ' and "
    ### $description

    if ($description =~ /^number of /i) {
      $characteristic{'count'} = 1;
    }
    # ENHANCE-ME: use __x() when available, or an sprintf "... %s" would be enough ...
    $description .= "\n" . (defined $self->{'filename'}
                            ? Math::NumSeq::__('Values from B-file ') . $self->{'filename'}
                            : Math::NumSeq::__('First few values from ') . "$anum.internal");
    $self->{'description'} = $description;
  }

  _set_characteristics ($self, $description,
                        $contents =~ /^%K (.*?)(<tt>|$)/m && $1);

  # the eishelp1.html says
  # %V,%W,%X lines for signed sequences
  # %S,%T,%U lines for non-negative sequences
  # though now %S is signed and unsigned both is it?
  #
  if (! $self->{'array'}) {
    my @samples;
    while ($contents =~ m{^%[VWX] (.*?)(</tt>|$)}mg) {
      push @samples, $1;
    }
    unless (@samples) {
      while ($contents =~ m{^%[STU] (.*?)(</tt>|$)}mg) {
        push @samples, $1;
      }
      unless (@samples) {
        croak "Oops list of values not found in ",$filename;
      }
    }
    _split_sample_values ($self, $filename,
                          join (', ', @samples),
                          $offset);
  }

  # %O "OFFSET" is subscript of first number, or for digit expansions it's
  # the position of the decimal point
  # http://oeis.org/eishelp2.html#RO
  if (! $self->{'characteristic'}->{'digits'}) {
    $self->{'i_start'} = $offset;
  }

  return 1; # success
}

sub _read_html {
  my ($self, $anum) = @_;
  ### _read_html(): $anum

  foreach my $basefile ("$anum.html", "$anum.htm") {
    my $filename = File::Spec->catfile (oeis_dir(), $basefile);
    ### $basefile
    ### $filename

    if (open FH, "< $filename") {
      my $contents = do { local $/; <FH> }; # slurp
      close FH or die;

      $contents = _decode_html_charset($contents);

      my $description;
      if ($contents =~
          m{$anum\n.*?
            <td[^>]*>\s*</td>   # blank <td ...></td>
            <td[^>]*>           # <td ...>
            \s*
            (.*?)               # text
            <(br|/td)>          # to <br> or </td>
         }sx) {
        $description = $1;
        $description =~ s/\s+$//;       # trailing whitespace
        $description =~ s/\s+/ /g;      # collapse whitespace
        $description =~ s/<[^>]*?>//sg; # tags <foo ...>
        $description =~ s/&lt;/</g;    # unentitize <
        $description =~ s/&gt;/>/g;    # unentitize >
        $description =~ s/&amp;/&/g;   # unentitize &
        $description =~ s/&#(\d+);/chr($1)/ge; # unentitize numeric ' and "
        ### $description

        # __x('Values from B-file {filename}',
        #     filename => $self->{'filename'})

        $description .= "\n" . (defined $self->{'filename'}
                                ? Math::NumSeq::__('Values from B-file ').$self->{'filename'}
                                : Math::NumSeq::__('First few values from HTML'));
        $self->{'description'} = $description;
      }

      # fragile grep out of the html ...
      my $offset = ($contents =~ /OFFSET.*?<tt>(\d+)/s
                    && $1);
      ### $offset

      # fragile grep out of the html ...
      my $keywords;
      if ($contents =~ m{KEYWORD.*?<tt[^>]*>(.*?)</tt>}s) {
        ### html keywords match: $1
        $keywords = $1;
      } else {
        # die "Oops, KEYWORD not matched: $anum";
      }
      _set_characteristics ($self, $description, $keywords);

      if (! $self->{'array'}) {
        # fragile grep out of the html ...
        $contents =~ s{>graph</a>.*}{};
        $contents =~ m{.*<tt>([^<]+)</tt>};
        my $list = $1;
        _split_sample_values ($self, $filename, $list, $offset);
      }

      # %O "OFFSET" is subscript of first number, or for digit expansions it's
      # the position of the decimal point
      # http://oeis.org/eishelp2.html#RO
      if (! $self->{'characteristic'}->{'radix'}) {
        $self->{'i_start'} = $offset;
      }

      return 1;
    }
    ### no html: $!
  }
  return 0;
}

sub _set_characteristics {
  my ($self, $description, $keywords) = @_;
  ### _set_characteristics()
  ### $description
  ### $keywords

  if (! defined $keywords) {
    return; # if perhaps match of .html failed
  }

  $keywords =~ s{<[^>]*>}{}g;  # <foo ...> tags
  ### $keywords

  foreach my $key (split /[, \t]+/, ($keywords||'')) {
    ### $key
    $self->{'characteristic'}->{"OEIS_$key"} = 1;
  }

  if ($self->{'characteristic'}->{'OEIS_cofr'}) {
    $self->{'characteristic'}->{'continued_fraction'} = 1;
  }

  # "cons" means decimal digits of a constant
  # but don't reckon A000012 all-ones that way
  # "base" means non-decimal, it seems, maybe
  if ($self->{'characteristic'}->{'OEIS_cons'}
      && ! $self->{'characteristic'}->{'OEIS_base'}
      && $self->{'anum'} ne 'A000012') {
    $self->{'values_min'} = 0;
    $self->{'values_max'} = 9;
    $self->{'characteristic'}->{'digits'} = 10;
  }

  if ($description =~ /expansion of .* in base (\d+)/i) {
    $self->{'values_min'} = 0;
    $self->{'values_max'} = $1 - 1;
    $self->{'characteristic'}->{'digits'} = $1;
  }
}

sub _split_sample_values {
  my ($self, $filename, $str, $offset) = @_;
  unless ($str =~ m{^([0-9,-]|\s)+$}) {
    croak "Oops unrecognised list of values not found in ",$filename,"\n",$str;
  }
  $self->{'array'} = [ split /[, \t\r\n]+/, $str ];
}

use constant::defer _HAVE_ENCODE => sub {
  eval { require Encode; 1 } || 0;
};
sub _decode_html_charset {
  my ($contents) = @_;

  # eg. <META http-equiv="content-type" content="text/html; charset=utf-8">
  # HTTP::Message has a blob of code for this, using the full HTTP::Parser,
  # but a slack regexp should be enough for OEIS pages.
  #
  if (_HAVE_ENCODE
      && $contents =~ m{<META[^>]+
                        http-equiv=[^>]+
                        content-type[^>]+
                        charset=([a-z0-9-_]+)}isx) {
    return Encode::decode($1, $contents, Encode::FB_PERLQQ());
  } else {
    return $contents;
  }
}

1;
__END__







  # foreach my $basefile (anum_to_html($anum), anum_to_html($anum,'.htm')) {
  #   my $filename = File::Spec->catfile (oeis_dir(), $basefile);
  #   ### $basefile
  #   ### $filename
  #   if (open FH, "<$filename") {
  #     my $contents = do { local $/; <FH> }; # slurp
  #     close FH or die;
  # 
  #     # fragile grep out of the html ...
  #     $contents =~ s{>graph</a>.*}{};
  #     $contents =~ m{.*<tt>([^<]+)</tt>};
  #     my $list = $1;
  #     unless ($list =~ m{^([0-9,-]|\s)+$}) {
  #       croak "Oops list of values not found in ",$filename;
  #     }
  #     my @array = split /[, \t\r\n]+/, $list;
  #     ### $list
  #     ### @array
  #     return \@array;
  #   }
  #   ### no html: $!
  # }
