package Weather::OpenWeatherMap::Cache;
$Weather::OpenWeatherMap::Cache::VERSION = '0.005002';
use Carp;
use strictures 2;

use Scalar::Util 'blessed';

use Storable ();
use Time::HiRes ();

use Digest::SHA 'sha1_hex';

use List::Objects::WithUtils;

use Path::Tiny;
use Try::Tiny;

use Types::Standard       -all;
use List::Objects::Types  -all;
use Types::Path::Tiny     -all;


use Moo; 


has dir => (
  lazy      => 1,
  is        => 'ro',
  isa       => AbsDir,
  coerce    => 1,
  builder   => sub { Path::Tiny->tempdir(CLEANUP => 1) },
);

has expiry => (
  is        => 'ro',
  isa       => StrictNum,
  builder   => sub { 1200 },
);

## FIXME max_entries ? or a size limit ?

sub serialize {
  my ($self, $obj) = @_;
  Storable::freeze(
    [ Time::HiRes::time, $obj ]
  )
}

sub deserialize {
  my ($self, $data) = @_;
  Storable::thaw($data)
}

sub make_path {
  my ($self, $obj) = @_;

  $obj = $obj->request
    if blessed($obj)
    and $obj->isa('Weather::OpenWeatherMap::Result');

  confess "Expected a Weather::OpenWeatherMap::Request but got $obj"
    unless blessed($obj) and $obj->isa('Weather::OpenWeatherMap::Request');

  my $fname = 'W';
  # also see cache_paths; new cache prefixes need added there also
  TYPE: {
    if ($obj->isa('Weather::OpenWeatherMap::Request::Current')) {
      $fname .= 'C';
      last TYPE
    }
    if ($obj->isa('Weather::OpenWeatherMap::Request::Forecast')) {
      $fname .= $obj->hourly ? 'H' : 'F';
      last TYPE
    }
    if ($obj->isa('Weather::OpenWeatherMap::Request::Find')) {
      $fname .= 'S';
      last TYPE
    }
    confess "Fell through; no clue what to do with $obj"
  }

  my $location = lc $obj->location;
  my $digest = $^O eq 'Win32' ? 
      substr sha1_hex($location), 0, 25 
    : sha1_hex($location);
  # If you happen to alter the extension, check ->clear() too:
  $fname .= $digest . '.wx';
  path( join '/', $self->dir->absolute, $fname )
}

sub cache {
  my ($self, @results) = @_;
  my $count = 0;
  for my $result (@results) {
    confess "Expected a Weather::OpenWeatherMap::Result but got $result"
      unless blessed($result) 
      and $result->isa('Weather::OpenWeatherMap::Result');

    my $request = $result->request;
    my $path   = $self->make_path($request);
    my $frozen = $self->serialize($result);
    $path->spew_raw($frozen);
    ++$count;
  }
  $count
}

sub is_cached {
  my ($self, $obj) = @_;
  my $path = $self->make_path($obj);
  return unless $path->exists;
  return if $self->expire($obj);
  $path
}

sub retrieve {
  my ($self, $request) = @_;
  # make_path (via is_cached) will handle Result objs transparently also, but
  # we need a Request so we can reattach it to returned Result later:
  $request = $request->request
    if $request->isa('Weather::OpenWeatherMap::Result');
  my $path = $self->is_cached($request);
  return unless $path;

  my $data = $path->slurp_raw;
  my $ref =
    try { $self->deserialize($data) }
    catch {
      warn "deserialize died on retrieve: $_\n";
      warn "Attempting to remove possibly corrupt cachefile: $path";
      $path->remove;
      undef
    };
  return unless $ref;

  my ($ts, $result) = @$ref;
  unless (defined $ts && defined $result) {
    warn "cachefile incomplete, removing: $path";
    $path->remove;
    return
  }

  # cached Request obj attached to Result may be stale
  # (e.g. tag may be different for new Request/Result pair)
  # FIXME tests for this are lacking
  $result->set_request($request);

  hash(
    cached_at => $ts,
    object    => $result
  )->inflate
}

sub expire {
  my ($self, $obj) = @_;
  return $self->expire_all unless defined $obj;
  my $path = is_Path($obj) ? $obj : $self->make_path($obj);
  return unless $path->exists;

  my $data = $path->slurp_raw;
  my $ref = 
    try { $self->deserialize($data) }
    catch {
      warn "deserialize died on expiry check: $_\n";
      warn "Attempting to remove possibly corrupt cachefile: $path";
      $path->remove;
      undef
    };
  return unless $ref;

  my ($ts) = @$ref;
  unless ($ts) {
    warn "expiring possibly corrupt cachefile: $path";
    return $path->remove
  }
  return $path->remove if Time::HiRes::time - $ts > $self->expiry;
  ()
}

sub cache_paths {
  my ($self) = @_;
  $self->dir->children( qr/^W(?:[CHFS]).+\.wx/ )
}

sub expire_all {
  my ($self) = @_;
  my @expired;
  POSSIBLE: for my $maybe ($self->cache_paths) {
    push @expired, "$maybe" if $self->expire($maybe)
  }
  @expired
}

sub clear {
  my ($self) = @_;
  my @removed;
  POSSIBLE: for my $maybe ($self->cache_paths) {
    try {
      my $data = $maybe->slurp_raw;
      my $ref  = $self->deserialize($data);
      my ($ts, $result) = @$ref;
      die 
        unless is_StrictNum($ts)
        and $result->isa('Weather::OpenWeatherMap::Result')
    } or next POSSIBLE;
    push @removed, "$maybe";
    # Looks like ours; remove() rather than unlink()
    # (we don't care if it exists or not, at this point)
    $maybe->remove
  }
  @removed
}


1;

=pod

=head1 NAME

Weather::OpenWeatherMap::Cache - Cache manager for OpenWeatherMap results

=head1 SYNOPSIS

  # Usually used via Weather::OpenWeatherMap

=head1 DESCRIPTION

A simple cache manager for L<Weather::OpenWeatherMap> results.

=head2 ATTRIBUTES

=head3 dir

The directory cache files are saved in.

Defaults to using a temporary directory that is cleaned up during object
destruction (via L<Path::Tiny> / L<File::Temp>).

If you specify a directory, no automated cleanup is done other than normal
object expiry checks during calls to L</retrieve>.

=head3 expiry

The duration (in seconds) cache files are considered valid; defaults to
C<1200>.

=head2 METHODS

=head3 High-level methods

=head4 cache

Takes a list of L<Weather::OpenWeatherMap::Result> objects and caches to
L</dir>.

Returns the number of items cached.

=head4 retrieve

Takes a L<Weather::OpenWeatherMap::Request> and attempts to retrieve a
(non-expired) cached L<Weather::OpenWeatherMap::Result>.

Returns false if no item was found.

If successful, the return value is a simple struct-like object with two
attributes, B<cached_at> (the C<time()> that the cached item was saved) and
B<object> (the relevant L<Weather::OpenWeatherMap::Result> object):

  my $result;
  if (my $cached = $cache->retrieve($request)) {
    $result = $cached->object
  }

=head3 Low-level methods

Subclasses can override the following methods to alter cache behavior.

=head4 cache_paths

Returns a list of L<Path::Tiny> objects representing (what appear to be)
L<Weather::OpenWeatherMap> cache files.

=head4 clear

Walk our L</dir>, removing any items that appear to belong to the cache.

Returns the list of removed paths (as strings).

=head4 deserialize

Takes a scalar containing serialized cache data and returns a Perl object or
data structure.

Uses L<Storable> by default.

=head4 expire

Given a L<Weather::OpenWeatherMap::Request> or
L<Weather::OpenWeatherMap::Result>, removes relevant stale cache data.

If passed no arguments, calls L</expire_all>.

Called by L</retrieve> before object retrieval.

Returns true if a cached object was expired.

=head4 expire_all

Expires any stale cache files found in L</dir>.

=head4 is_cached

Takes a L<Weather::OpenWeatherMap::Request> or
L<Weather::OpenWeatherMap::Result> and returns boolean true if the object is
cached.

=head4 make_path

Takes a L<Weather::OpenWeatherMap::Request> or
L<Weather::OpenWeatherMap::Result> and returns an appropriate L<Path::Tiny>
object representing the path that would be used to cache or retrieve the
object.

=head4 serialize

Takes a Perl object or data structure and returns serialized cache data
suitable for writing to disk.

Uses L<Storable> by default.

=head1 AUTHOR

Jon Portnoy <avenj@cobaltirc.org>

=cut
