Question 6: Spreadsheet source 

package CodeSheet::Spreadsheet;

use Moose;

use Try::Tiny;
use Digest::MD5   qw(md5_hex);
use Data::Dumper  qw();

has 'qnum'         => ( is => 'rw', isa => 'Int' );
has 'max_qnum'     => ( is => 'rw', isa => 'Int', default => 100 );
has 'cell_data'    => ( is => 'rw' );
has 'input_hash'   => ( is => 'rw', isa => 'Str' );
has 'random_data'  => ( is => 'rw' );
has 'last_hash'    => ( is => 'rw', isa => 'Str' );
has 'formula'      => ( is => 'rw', isa => 'Str' );
has 'solution'     => ( is => 'rw', isa => 'Str' );
has 'message'      => ( is => 'rw', isa => 'Str' );
has 'secret_token' => ( is => 'rw', isa => 'Str' );


my @cols = 'A' .. 'Z';
my @rows =  1  .. 100;

sub new_initial {
    my $class = shift;
    my $random_hash = md5_hex(rand());
    my $self = $class->new(
        qnum        => 1,
        input_hash  => $random_hash,
    );
    $self->populate_cell_data;
    $self->generate_formula;
    return $self;
}


sub new_from_post {
    my($class, $args) = @_;

    my($self, $state);
    try {
        $state = $class->decode_token($args->{csrf_token});
        $self = $class->new(%$state);
        $self->populate_cell_data;
        $self->generate_formula;
        $args->{start_time} = $state->{timestamp};
        $self->validate_solution($args);

        # That seemed OK, so generate the next question
        $self = $class->new(
            qnum        => 0 + $self->next_qnum,
            input_hash  => $self->last_hash,
        );
        $self->populate_cell_data;
        $self->generate_formula;
    }
    catch {
        $self = $class->new(message => $_);
    };
    return $self;
}


sub csrf_token {
    my $self = shift;
    my $timestamp   = sprintf('%04x', time() % 43200);
    my $qnum        = sprintf('%02x', $self->qnum);
    my $plain_text  = $timestamp . $qnum . $self->input_hash;
    my $checksum    = substr(md5_hex($plain_text), 0, 4);
    my $key = int(rand(256));
    return join '', sprintf('%02x', $key), $checksum, map {
        sprintf('%02x', hex($_) ^ $key);
    } $plain_text =~ m{(..)}g;
}


sub decode_token {
    my($class, $token) = @_;
    my($key, $checksum, $rest) = $token =~ m{\A(..)(....)(.*)\z};
    $key = hex($key);
    my $plain_text = join '', map {
        sprintf('%02x', hex($_) ^ $key);
    } $rest =~ m{(..)}g;
    die "Invalid CSRF token. Did you do something naughty?\n"
        if $checksum ne substr(md5_hex($plain_text), 0, 4);
    my($timestamp, $qnum, $input_hash)
        = $plain_text =~ m{\A(....)(..)(.*)\z};
    return {
        timestamp   => hex($timestamp),
        qnum        => hex($qnum),
        input_hash  => $input_hash,
    };
}


sub validate_solution {
    my($self, $args) = @_;

    die "Someone appears to have tampered with the formula.\n"
        unless $self->formula eq $args->{formula};
    die "'$args->{solution}' is not the correct solution.\n"
        unless $self->solution eq $args->{solution};
    my $now = time % 43200;
    my $elapsed = ($now + 43200 - $args->{start_time}) % 43200;
    if($elapsed > 5) {
        die "You had the correct answer but took too long.  "
          . "You need to supply a solution within 5 seconds.\n";
    }
    return;
}


sub next_random_val {
    my $self = shift;
    my $data = $self->random_data || [];
    if(!@$data) {
        my $hash = md5_hex( $self->last_hash || $self->input_hash);
        $self->last_hash($hash);
        $data = [
            map { hex($_) } $hash =~ m{(..)}g
        ];
        $self->random_data($data);
    }
    return shift(@$data);
}


sub populate_cell_data {
    my $self = shift;
    my %data;
    foreach my $r (@rows) {
        foreach my $c (@cols) {
            $data{"$c$r"} = $self->next_random_val();
        }
    }
    $self->cell_data(\%data);
}


sub generate_formula {
    my($self, $stage) = @_;

    $stage //= $self->qnum;

    if($stage > $self->max_qnum) {
        return $self->secret_token("B75KTRJ6");
    }
    elsif($stage > 54) {
        return $self->compound_addition();
    }
    elsif($stage > 45) {
        return $self->list_max(2);
    }
    elsif($stage > 36) {
        return $self->square_root();
    }
    elsif($stage > 27) {
        return $self->simple_division();
    }
    elsif($stage > 18) {
        return $self->simple_multiplication();
    }
    elsif($stage > 9) {
        return $self->simple_subtraction();
    }
    else {
        return $self->simple_addition($self->qnum == 1);
    }
}


sub simple_addition {
    my($self, $visible) = @_;
    my $ref1 = $self->random_cell($visible);
    my $ref2 = $self->random_cell($visible);
    $self->formula("$ref1 + $ref2");
    $self->solution(
        $self->cell_value($ref1) + $self->cell_value($ref2)
    );
}


sub compound_addition {
    my($self, $visible) = @_;
    $self->generate_formula($self->qnum - 54);
    my $ref1 = $self->formula;
    my $val1 = $self->solution;
    my $ref2 = $self->random_cell($visible);
    $self->formula("$ref1 + $ref2");
    $self->solution($val1 + $self->cell_value($ref2));
}


sub simple_subtraction {
    my($self) = @_;
    my $ref1 = $self->random_cell();
    my $ref2 = $self->random_cell();
    my $val1 = $self->cell_value($ref1);
    my $val2 = $self->cell_value($ref2);
    if($val2 > $val1) {
        ($ref2, $ref1) = ($ref1, $ref2);
        ($val2, $val1) = ($val1, $val2);
    }
    $self->formula("$ref1 - $ref2");
    $self->solution($val1 - $val2);
}


sub simple_multiplication {
    my($self) = @_;
    my $ref1 = $self->random_cell();
    my $ref2 = $self->random_cell();
    $self->formula("$ref1 * $ref2");
    $self->solution(
        $self->cell_value($ref1) * $self->cell_value($ref2)
    );
}


sub simple_division {
    my($self) = @_;
    my $data = $self->cell_data;
    my @ref_list1 = $self->random_keys();
    my @ref_list2 = $self->random_keys();
    foreach my $ref1 (@ref_list1) {
        my $val1 = $data->{$ref1};
        next unless $val1 > 1;
        foreach my $ref2 (@ref_list2) {
            next if $ref1 eq $ref2;
            my $val2 = $data->{$ref2};
            next unless $val2 > 1;
            next unless $val1 > $val2;
            if($val1 % $val2 == 0) {
                $self->formula("$ref1 / $ref2");
                $self->solution($val1 / $val2);
                return;
            }
        }
    }
    return $self->simple_subtraction;
}


sub square_root {
    my($self) = @_;
    my $data = $self->cell_data;
    my @ref_list = $self->random_keys();
    foreach my $ref (@ref_list) {
        my $val = $data->{$ref};
        my $root = sqrt($val);
        if($root == int($root)) {
            $self->formula("SQRT($ref)");
            $self->solution($root);
            return;
        }
    }
    return $self->simple_division;
}


sub list_max {
    my($self, $count) = @_;
    my @refs = ();
    my $max  = 0;
    foreach (1 .. $count) {
        my $ref = $self->random_cell();
        my $val = $self->cell_value($ref);
        push @refs, $ref;
        $max = $val if $val > $max;
    }
    $self->formula("MAX(" . join(', ', @refs) . ")");
    $self->solution($max);
}


sub random_cell {
    my($self, $visible) = @_;
    my($max_c, $max_r) = $visible ? (10, 10) : (25, 99);
    my $c = $cols[ $self->next_random_val() * $max_c  / 255];
    my $r = $rows[ $self->next_random_val() * $max_r / 255];
    return "$c$r";
}


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

    my $data = $self->cell_data;
    my $key  = $self->next_random_val();
    return sort {
        ($data->{$b} ^ $key) <=> ($data->{$a} ^ $key)
        || $a cmp $b
    } keys %$data;
}


sub cell_value {
    my($self, $ref) = @_;

    my $data = $self->cell_data;
    return $data->{$ref};
}


sub next_qnum {
    my $self = shift;
    return sprintf('%03u', $self->qnum + 1);
}


sub is_error {
    my $self = shift;
    return $self->message ? 1 : 0;
}


sub data_as_html {
    my $self = shift;

    my @tr;
    push @tr, "<tr><th></th>" . join('', map {
        qq{<th class="col"><span>$_</span></th>};
    } @cols) . "</tr>";
    foreach my $r (@rows) {
        push @tr, qq{<tr><th class="row"><span>$r</span></th>}
            . join('', map {
                my $val = $self->cell_value("$_$r");
                qq{<td>$val</td>}
            } @cols) . "</tr>";
    }
    return join "\n",
        q{<table class="spreadsheet">}, @tr, "</table>";
}

1;