#!/usr/bin/perl
# 
# Hey folks - here is my solution for the week 7 expert edition.  I took an
# OO approach, so there are several classes involved.  For convenience, they
# are all appended into the same script file which is included below.  In a
# nutshell, it uses the following classes:
# 
# Frost::Base - base class for Frost:: objects
# Frost::Grid - class to hold grid data structure
# Frost::Neighborhood - class to hold logic within the 2x2 neighborhoods
# Frost::Simulation::Base - base class for simulations
# Frost::Simulation::(Console||Tk|File) - front-ends for simulation output
# 
# Basically Grid and Neighborhood provide the parts, Simulation::Base
# provides the engine, and Simulation::(Console|Tk|File) provide three
# different models.  It would be pretty easy to drop in a new simulation
# object that has better configuration parameters, creates movies, etc.
# This was a fun quiz!
# 
# --Ben Kennedy
# 
# 

# base class for Frost classes - provides a generic
#  get/set method that derived classes will use for
#  variable storage
#
package Frost::Base;

use strict;

sub _gss { @_ > 2 ? $_[0]{$_[1]} = $_[2] : $_[0]{$_[1]} }




#
# This class encapsulates a grid of cells, where each cell
#  can have vapor, air, or ice.  It provides mechanisms
#  for getting and setting cell contents.  It also has a method
#  called "get_margolis_neighborhoods" that partitions the
#  grid into the 2x2 neighborhoods.  These neighborhoods are
#  in fact lists of scalar references, suitable for the
#  Frost::Neighborhood class
#
package Frost::Grid;

use strict;

use constant CONTENT_AIR => " ";
use constant CONTENT_VAPOR => ".";
use constant CONTENT_ICE => "*";

our @EXPORT = qw(CONTENT_AIR CONTENT_VAPOR CONTENT_ICE);

use base qw(Frost::Base Exporter);

sub width { shift->_gss("WIDTH", @_) }
sub height { shift->_gss("HEIGHT", @_) }
sub _data_ref { shift->_gss("DATA", @_) }

sub new {
  my $package = shift;
  my %params = @_;
  my $self = bless { }, $package;
  my $width = $params{-width};
  my $height = $params{-height};
  $self->_validate_size_parameter($width);
  $self->_validate_size_parameter($height);
  $self->width($width);
  $self->height($height);

  $self->_data_ref([]);

  for (my $i = 0 ; $i < $width ; $i++) {
    for (my $j = 0 ; $j < $height ; $j++) {
      $self->insert_air($i, $j);
    }
  }

  return $self;
}

sub insert_vapor {
  my($self, $x_coord, $y_coord) = @_;
  $self->set_contents($x_coord, $y_coord, CONTENT_VAPOR);
}

sub insert_ice {
  my($self, $x_coord, $y_coord) = @_;
  $self->set_contents($x_coord, $y_coord, CONTENT_ICE);
}

sub insert_air {
  my($self, $x_coord, $y_coord) = @_;
  $self->set_contents($x_coord, $y_coord, CONTENT_AIR);
}

sub cell_is_ice {
  my($self, $x_coord, $y_coord) = @_;
  $self->get_contents($x_coord, $y_coord) eq CONTENT_ICE;
}

sub cell_is_vapor {
  my($self, $x_coord, $y_coord) = @_;
  $self->get_contents($x_coord, $y_coord) eq CONTENT_VAPOR;
}

sub cell_is_air {
  my($self, $x_coord, $y_coord) = @_;
  $self->get_contents($x_coord, $y_coord) eq CONTENT_AIR;
}

sub get_contents {
  my($self, $x_coord, $y_coord) = @_;
  $self->_validate_coords($x_coord, $y_coord);
  $self->_data_ref->[$x_coord][$y_coord];
}

sub set_contents {
  my($self, $x_coord, $y_coord, $type) = @_;
  $self->_validate_coords($x_coord, $y_coord);
  $self->_data_ref->[$x_coord][$y_coord] = $type;
}

sub content_ref {
  my($self, $x_coord, $y_coord) = @_;
  $self->_validate_coords($x_coord, $y_coord);
  return \$self->_data_ref->[$x_coord][$y_coord];
}

sub _validate_size_parameter {
  my($self, $size) = @_;
  defined $size or die "must pass parameter";
  $size > 0 or die "must pass positive size";
  $size % 2 and die "must pass even number as size";
}

sub _validate_coords {
  my($self, $x_coord, $y_coord) = @_;

  defined($x_coord) or die "x-coordinate not defined";
  $x_coord < $self->width or die "x-coord $x_coord is too large";
  $x_coord >= 0 or die "x-coord $x_coord is < 0";

  defined($y_coord) or die "y-coordinate not defined";
  $y_coord < $self->height or die "y-coord $y_coord is too large";
  $y_coord >= 0 or die "y-coord $y_coord is < 0";
}

#
# returns the set of 2x2 Margolis neighborhoods whose upper-left cell
#  passes through the specified x and y coords.  Returns them as
#  a list of scalar references to the grid data so that the class
#  that eventually processess them will not need to know or
#  care about wrapping rows and columns, etc
#
sub get_margolis_neighborhoods {
  my($self, $x_coord, $y_coord) = @_;
  $self->_validate_coords($x_coord, $y_coord);

  # move to equivalent cell
  $x_coord %= 2;
  $y_coord %= 2;

  my @back;
  for (my $i = $x_coord ; $i < $self->width ; $i += 2) {
    for (my $j = $y_coord ; $j < $self->height ; $j += 2) {
      my($x1, $y1) = ($i, $j);
      my($x2, $y2);

      # wrap from right edge to left or bottom to top if necessary
      ($i == ($self->width - 1)) ? ($x2 = 0) : ($x2 = $i + 1);
      ($j == ($self->height - 1)) ? ($y2 = 0) : ($y2 = $j + 1);

      # return reference to group
      my $ref1 = $self->content_ref($x1, $y1);
      my $ref2 = $self->content_ref($x1, $y2);
      my $ref3 = $self->content_ref($x2, $y2);
      my $ref4 = $self->content_ref($x2, $y1);
      push @back, [
		   $ref1, $ref2, $ref3, $ref4
		  ];
    }
  }
  return @back;
}

#
# returns grid as a formatted scalar
#
sub grid_as_scalar {
  my $self = shift;

  my $back = '';

  $back .= "+" . "-" x $self->width . "+\n";
  for (my $j = 0 ; $j < $self->height ; $j++) {
    $back .= "|";
    for (my $i = 0 ; $i < $self->width ; $i++) {
      my $pip = $self->get_contents($i, $j);
      $back .= "$pip";
    }
    $back .= "|\n";
  }

  $back .= "+" . "-" x $self->width . "+\n";

  return $back;
}



#
# This class encapsulates the logic within the Margolis
#  neighborhoods.  The constructor requires a list of
#  scalar references that will generally point to locations
#  within the grid
#
package Frost::Neighborhood;

use base qw(Frost::Base);

sub _cells { shift->_gss("CELLS", @_) }

sub new {
  my $package = shift;
  my %params = @_;
  my $self = bless { }, $package;

  my $cells = $params{-cells};
  ref($cells) eq 'ARRAY' or die "Must pass array ref of grid cells";
  $self->_cells($cells);

  return $self;
}

#
# process the cells and grid given in
#  the constructor
#
sub modify_grid {
  my($self, $grid) = @_;

  # optimization - if a cell has no vapor in
  #  it, there is nothing to do
  return unless $self->_has_vapor;

  if ($self->_has_ice) {
    # if a cell has ice, convert any vapor particles
    $self->_convert_vapor;
  } else {
    # others rotate the cell
    if (int(rand() * 2)) {
      $self->_rotate(1);
    } else {
      $self->_rotate(0);
    }
  }
}


# if the rotate parameter is true, it will
#  reverse the direction the vapor particles
#  will move
#
sub _rotate {
  my($self, $rotate) = @_;

  my @vals = map { $$_ } @{$self->_cells};

  if ($rotate) {
    my $move = pop(@vals);
    unshift(@vals, $move);
  } else {
    my $move = shift(@vals);
    push @vals, $move;
  }

  foreach my $cell (@{$self->_cells}) {
    $$cell = shift @vals;
  }
}

#
# convert all vapor cells to ice cells
#
sub _convert_vapor {
  my $self = shift;
  foreach my $cell (@{$self->_cells}) {
    if ($$cell eq Frost::Grid::CONTENT_VAPOR()) {
      $$cell = Frost::Grid::CONTENT_ICE();
    }
  }
}

#
# return true if any of the 2x2 cells has ice
#
sub _has_ice {
  my($self) = @_;
  foreach my $cell (@{$self->_cells}) {
    if ($$cell eq Frost::Grid::CONTENT_ICE()) {
      return(1);
    }
  }
  return(0);
}

#
# return true if any of the 2x2 cells has vapor
#
sub _has_vapor {
  my($self) = @_;
  foreach my $cell (@{$self->_cells}) {
    if ($$cell eq Frost::Grid::CONTENT_VAPOR()) {
      return(1);
    }
  }
  return(0);
}

#
# Base class for frost simulation runs - this does
#  all the setup with grids and neighborhoods, but leaves
#  the details of the display up to the derived classes
#
package Frost::Simulation::Base;

use base 'Frost::Base';

sub _width { shift->_gss("WIDTH", @_) }
sub _height { shift->_gss("HEIGHT", @_) }
sub _limit { shift->_gss("LIMIT", @_) }
sub _grid { shift->_gss("GRID", @_) }
sub _delay { shift->_gss("DELAY", @_) }
sub _time_index { shift->_gss("TIME_INDEX", @_) }
sub _neighborhoods { shift->_gss("NEIGHBORHOODS", @_) }

sub new {
  my $package = shift;
  my %params = @_;

  my $self = bless { }, $package;

  my $width = $params{-width};
  my $height = $params{-height};
  my $limit = $params{-limit} || 0;
  my $delay = $params{-delay} || 0;
  $self->_width($width);
  $self->_height($height);
  $self->_limit($limit);
  $self->_delay($delay);

  $self->init_sim(%params);

  return $self;
}

#
# should be called to run the simulation
#
sub run_simulation {
  my $self = shift;
  $self->init_display;
  $self->_run_iterations;
  $self->finish_display;
  $self->finish_sim;
}

#
# called by constructor, sets up grids and neighborhoods
#
sub init_sim {
  my $self = shift;

  my $grid = new Frost::Grid (
			      -width => $self->_width,
			      -height => $self->_height
			     );


  my @neigh_1 = $grid->get_margolis_neighborhoods(0,0);
  my @neigh_2 = $grid->get_margolis_neighborhoods(1,1);

  my @m1 = map { new Frost::Neighborhood(-cells => $_,
					 ) } @neigh_1;
  my @m2 = map { new Frost::Neighborhood(-cells => $_,
					 ) } @neigh_2;

  $self->_neighborhoods( [ \@m1, \@m2 ] );
  $self->_grid($grid);

  for (my $i = 1 ; $i < $self->_width ; $i += 3) {
    for (my $j = 1 ; $j < $self->_height ; $j += 3) {
      $grid->insert_vapor($i, $j);
    }
  }
  $grid->insert_ice(int($self->_width / 2), int($self->_height / 2));
}

#
# called by run_simulation - derived class should initialze
#  display here
#
sub init_display {
  my $self = shift;
}

#
# called by run_similation, does not need to be overridden - performs
#  transformation on grid, and calles update_display() which should
#  be overridden by derived class
#
sub _run_iterations {
  my $self = shift;

  my $time_index = 0;

  local $SIG{INT} = sub { print "* Int\n"; $self->_limit(1) };

  while((not $self->_limit) or ($time_index++ < $self->_limit)) {
    $self->_time_index($time_index);

    # modidy the grid for the cells in the appropriate neighborhood
    $_->modify_grid($self->_grid) foreach
      @{$self->_neighborhoods->[$time_index % 2]};

    # let derived class update display
    $self->update_display;

#    select(undef, undef, undef, $self->_delay);
  }
}

#
# called by _run_iterations() - should be overridden by derived class to
#  update the display
#
sub update_display {
  my $self = shift;
}

#
# Called once just after the final time tick - derived class can do
#  something special with the final product or pause
#
sub finish_display {
  my $self = shift;
}

#
# Called after finish_display() - should be used to cleanup
#
sub finish_sim {
  my $self = shift;
}

package Frost::Simulation::Console;

#
# Dump grid to console after every tick
#

use base 'Frost::Simulation::Base';

sub _clear { shift->_gss("CLEAR", @_) }

sub init_sim {
  my $self = shift;
  $self->SUPER::init_sim(@_);

  my $clear;

  if ($ENV{TERM}) {
    require Term::Cap;
    $clear = Term::Cap->Tgetent( { OSPEED => 9600 } )->Tputs('cl');
  }


  $self->_clear($clear);
}

sub update_display {
  my $self = shift;

  if ($^O =~ /win32/i) {
    system("cls");
  }



  print $self->_clear . "* Time index " . $self->_time_index . "\n";
  print $self->_grid->grid_as_scalar;
}

package Frost::Simulation::Tk;

use base 'Frost::Simulation::Base';

sub _zoom { shift->_gss("ZOOM", @_) }
sub _mw { shift->_gss("MW", @_) }
sub _header { shift->_gss("HEADER", @_) }
sub _canvas { shift->_gss("CANVAS", @_) }


sub init_sim {
  my $self = shift;
  $self->SUPER::init_sim(@_);

  my %params = @_;
  $self->_zoom($params{-zoom} || 1);

  eval "require Tk;";
  if ($@ and $@ =~ /can\'t locate/i) {
    die "Required module Tk not found";
  }

  my $mw = new MainWindow(-title => "Frost Simulation");
  my $label = $mw->Label->pack(-fill => 'x');
  my $canvas = $mw->Canvas(-width => $self->_width * $self->_zoom,
			   -height => $self->_height * $self->_zoom,
			   -background => '#EEEEEE')->pack;

  $self->_mw($mw);
  $self->_header($label);
  $self->_canvas($canvas);


}

sub update_display {
  my $self = shift;

  $self->_header->configure(-text => "Time index: " . $self->_time_index);

  $self->_canvas->delete('dots');

  my $zoom = $self->_zoom;

  for (my $j = 0 ; $j < $self->_grid->height ; $j++) {
    for (my $i = 0 ; $i < $self->_grid->width ; $i++) {
      my @coords = ($i * $zoom,
		    $j * $zoom,
		    ($i * $zoom) + ($zoom),
		    ($j * $zoom) + ($zoom)
		   );

      if ($self->_grid->cell_is_ice($i, $j)) {
	$self->_canvas->createRectangle(@coords,
					-fill => '#000044',
					-outline => '#000044',
					-tags => [ 'dots' ]);
      } elsif ($self->_grid->cell_is_vapor($i, $j)) {
	$self->_canvas->createRectangle(@coords,
					-fill => '#99CCCC',
					-outline => '#99CCCC',
					-tags => [ 'dots' ]);
      }
    }
  }


  $self->_mw->update;
}


sub finish_display {
  my $self = shift;
  $self->_mw->Button(-text => 'Done',
		     -command => sub {
		       $self->_mw->destroy
		       })->pack;
  Tk::MainLoop();
}

package Frost::Simulation::File;

use base 'Frost::Simulation::Base';


sub _zoom { shift->_gss("ZOOM", @_) }
sub _file { shift->_gss("FILE", @_) }
sub _image { shift->_gss("IMAGE", @_) }

sub _color_ice { shift->_gss("COL_ICE", @_) }
sub _color_vapor { shift->_gss("COL_VAPOR", @_) }


sub init_sim {
  my $self = shift;
  $self->SUPER::init_sim(@_);

  my %params = @_;
  $self->_zoom($params{-zoom} || 1);
  $self->_file($params{-file} || "out.png");

  if (not $self->_limit) {
    $self->_limit(250);
  }
  $self->_delay(0);

  eval "require GD";
  if ($@ and $@ =~ /can\'t locate/i) {
    die "Required module GD not found";
  }

  print STDERR "Output file: " . $self->_file . "\n";
  print STDERR "Iterations: " . $self->_limit . "\n";
  print STDERR "Grid width: " . $self->_width . "\n";
  print STDERR "Grid height: " . $self->_height . "\n";
  print STDERR "Zoom level: " . $self->_zoom . "\n";

}

sub update_display {
  my $self = shift;
  $self->SUPER::update_display(@_);

  my $percent_done = sprintf("%.2f", $self->_time_index /
			     $self->_limit * 100);

  print STDERR "* At " . $self->_time_index . " ($percent_done%)\n";
}

sub finish_display {
  my $self = shift;

  my $im = new GD::Image($self->_width * $self->_zoom,
			 $self->_height * $self->_zoom);

  my $col_ice = $im->colorAllocate(0, 0, 68);
  my $col_vapor = $im->colorAllocate(153, 204, 204);
  my $col_air = $im->colorAllocate(238, 238, 238);

  my $zoom = $self->_zoom;

  for (my $j = 0 ; $j < $self->_grid->height ; $j++) {
    for (my $i = 0 ; $i < $self->_grid->width ; $i++) {
      my @coords = ($i * $zoom,
		    $j * $zoom,
		    ($i * $zoom) + ($zoom),
		    ($j * $zoom) + ($zoom)
		   );

      if ($self->_grid->cell_is_ice($i, $j)) {
	$im->filledRectangle(@coords, $col_ice);
      } elsif ($self->_grid->cell_is_vapor($i, $j)) {
	$im->filledRectangle(@coords, $col_vapor);
      } else {
	$im->filledRectangle(@coords, $col_air);
      }
    }
  }

  open(my $out, ">" . $self->_file) or die "Cannot open file: $!";
  binmode($out);
  print $out $im->png;
  close($out);

  print STDERR "* File written\n";

}


package main;

#
# set the block to 'true' that you're interested in running
#

my $sim;

$SIG{ALRM} = sub { warn "TICKS: $sim->{TIME_INDEX}\n"; exit };
alarm $ENV{TIMELIMIT} || 120;
my $BIG = 1_000_000_000;

if (1) {
  # reasonsable console run
  $sim = new Frost::Simulation::Console(
				    -width => 100,
				    -height => 100,
				    -limit => $BIG,
				    -delay => .01,
				   );
  $sim->run_simulation;
} elsif (0) {
  # reasonable Tk run
  $sim = new Frost::Simulation::Tk(
				    -width => 40,
				    -height => 40,
				    -limit => $BIG,
				    -delay => .01,
				    -zoom => 5
				   );
  $sim->run_simulation;
} elsif (0) {
  # reasonable file run
  $sim = new Frost::Simulation::File(
				    -width => 300,
				    -height => 300,
				    -limit => $BIG,
				    -zoom => 3,
                                    -file => "out2.png",
				   );
  $sim->run_simulation;
}

