package Apache::ASP::State;

use MLDBM;
use MLDBM::Sync 0.25;
use MLDBM::Sync::SDBM_File;
use SDBM_File;
use Data::Dumper;

use strict;
no strict qw(refs);
use vars qw(%DB %CACHE $DefaultGroupIdLength);
use Fcntl qw(:flock O_RDWR O_CREAT);
$DefaultGroupIdLength = 2;

# Database formats supports and their underlying extensions
%DB = (
       SDBM_File => ['.pag', '.dir'],
       DB_File => [''],
       'MLDBM::Sync::SDBM_File' => ['.pag', '.dir'],
       GDBM_File => [''],
       'Tie::TextDir' => [''],
       );

# About locking, we use a separate lock file from the SDBM files
# generated because locking directly on the SDBM files occasionally
# results in sdbm store errors.  This is less efficient, than locking
# to the db file directly, but having a separate lock file works for now.
#
# If there is no $group given, then the $group will be extracted from
# the $id as the first 2 letters of that group.
#
# If the group and the id are the same length, then what was passed
# was just a group id, and the object is being created for informational
# purposes only.  So, we don't create a lock file in this case, as this
# is not a real State object
#
sub new {
    my($asp, $id, $group) = @_;

    if($id) {
	$id =~ tr///;
    } else {
	$asp->Error("no id: $id passed into new State");
	return;
    }

    # default group is first 2 characters of id, simple hashing
    if($group) {
	$group =~ tr///;
    } else {
	$group = substr($id, 0, $DefaultGroupIdLength)
    }

    unless($group) {
	$asp->Error("no group defined for id $id");
	return;
    }

    my $state_dir = $asp->{state_dir};
    my $group_dir = $state_dir.'/'.$group;
    my $lock_file = $group_dir.'/'.$id.'.lock';
    my $file = $group_dir.'/'.$id;

    # we only need SDBM_File for internal, and its faster so use it
    my($state_db, $state_serializer);
    if($id eq 'internal') {
	$state_db = $Apache::ASP::DefaultStateDB;
	$state_serializer = $Apache::ASP::DefaultStateSerializer;
    } elsif($asp->{Internal} && (length($id) > $DefaultGroupIdLength)) {
	# don't get data for dummy group id sessions
	my $internal = $asp->{Internal};
	my $idata = $internal->{$id};
	if(! $idata->{state_db} || ! $idata->{state_serializer}) {
	    $state_db = $idata->{state_db} || $asp->{state_db} || $Apache::ASP::DefaultStateDB;
	    $state_serializer = $idata->{state_serializer} || 
	      $asp->{state_serializer} || $Apache::ASP::DefaultStateSerializer;
	    
	    # INIT StateDB && StateSerializer if hitting for the first time
	    # only if real id like a session id or application
	    if(length($id) > $DefaultGroupIdLength) {
		my $diff = 0;
		if(($idata->{state_db} || $Apache::ASP::DefaultStateDB) ne $state_db) {
		    $idata->{state_db} = $state_db;
		    $diff = 1;
		}
		if(($idata->{state_serializer} || $Apache::ASP::DefaultStateSerializer) ne $state_serializer) {
		    $idata->{state_serializer} = $state_serializer;
		    $diff = 1;
		}

		if($diff) {
		    $asp->{dbg} && $asp->Debug("setting internal data for state $id", $idata);
		    $internal->{$id} = $idata;
		}
	    }
	} else {
	    # this state has already been created
	    $state_db = $idata->{state_db};
	    $state_serializer = $idata->{state_serializer};
	}
    } else {
	# cache layer doesn't need internal
	($state_db, $state_serializer) = ($asp->{state_db}, $asp->{state_serializer});
    }

    my $self = 
      bless {
	     asp=>$asp,
	     dbm => undef, 
	     'dir' => $group_dir,
	     id => $id, 
	     file => $file,
	     group => $group, 
	     group_dir => $group_dir,
	     reads => 0,
	     state_dir => $state_dir,
	     writes => 0,
	    };

    # short circuit before expensive directory tests for group stub
    if ($group eq $id) {
	return $self;
    }

    if($asp->config('StateAllWrite')) {
	$asp->{dbg} and $asp->{state_all_write} = 1;
	$self->{dir_perms} = 0777;
	$self->{file_perms} = 0666;
    } elsif($asp->config('StateGroupWrite')) {
	$asp->{dbg} and $asp->{state_group_write} = 1;
	$self->{dir_perms} = 0770;
	$self->{file_perms} = 0660;
    } else {
	$self->{dir_perms} = 0750;
	$self->{file_perms} = 0640;
    }

#    push(@{$self->{'ext'}}, @{$DB{$self->{state_db}}});    
#    $self->{asp}->Debug("db ext: ".join(",", @{$self->{'ext'}}));

    # create state directories
    my @create_dirs;
    unless(-d $state_dir) {
	push(@create_dirs, $state_dir);
    }
    # create group directory
    unless(-d $group_dir) {
	push(@create_dirs, $group_dir);
    }
    if(@create_dirs) {
	$self->UmaskClear;
	for my $create_dir (@create_dirs) {
#	    $create_dir =~ tr///; # this doesn't work to untaint with perl 5.6.1, use old method
	    $create_dir =~ /^(.*)$/s;
	    $create_dir = $1;
	    if(mkdir($create_dir, $self->{dir_perms})) {
		$asp->{dbg} && $asp->Debug("creating state dir $create_dir");
	    } else {
		my $error = $!;
		-d $create_dir || $self->{asp}->Error("can't create group dir $create_dir: $error");
	    }
	}
	$self->UmaskRestore;
    }

    # INIT MLDBM::Sync DBM
    { 
	local $MLDBM::UseDB = $state_db || 'SDBM_File';
	local $MLDBM::Serializer = $state_serializer || 'Data::Dumper';
	# clear current tied relationship first, if any
	$self->{dbm} = undef; 
	local $SIG{__WARN__} = sub {};
	
	my $error;
	$self->{file} =~ /^(.*)$/; # untaint
	$self->{file} = $1;
	local $MLDBM::RemoveTaint = 1;
	$self->{dbm} = &MLDBM::Sync::TIEHASH('MLDBM', $self->{file}, O_RDWR|O_CREAT, $self->{file_perms});
	$asp->{dbg} && $asp->Debug("creating dbm for file $self->{file}, db $MLDBM::UseDB, serializer: $MLDBM::Serializer");
	$error = $! || 'Undefined Error';


	if(! $self->{dbm}) {
	    $self->{asp}->Error(qq{
Cannot tie to file $self->{file}, $error !!
Make sure you have the permissions on the directory set correctly, and that your
version of Data::Dumper is up to date. Also, make sure you have set StateDir to 
to a good directory in the config file.  StateDir defaults to Global/.state
});
	}
    }

    $self;
}

sub Init   { shift->{dbm}->CLEAR(); }
sub Size   { shift->{dbm}->SyncSize; }
sub Delete { shift->{dbm}->CLEAR(); }
sub WriteLock { shift->{dbm}->Lock; }
sub ReadLock { shift->{dbm}->ReadLock; }
sub UnLock { shift->{dbm}->UnLock; }

sub DeleteGroupId {
    my $self = shift;

    my $group_dir = $self->{group_dir};
    if(-d $group_dir) {
	$self->{asp}{Internal}->LOCK;
	if(rmdir($group_dir)) {
	    $self->{asp}->Debug("deleting group dir $group_dir");
	} else {
	    $self->{asp}->Log("cannot delete group dir $group_dir: $!");
	}
	$self->{asp}{Internal}->UNLOCK;
    }
}

sub GroupId { shift->{group}; }

sub GroupMembers {
    my $self = shift;
    local(*DIR);
    my(%ids, @ids);

    unless(opendir(DIR, $self->{group_dir})) {
	$self->{asp}->Log("opening group $self->{group_dir} failed: $!");
	return [];
    }

    for(readdir(DIR)) {
	next if /^\.\.?$/;
	$_ =~ /^(.*?)(\.[^\.]+)?$/;
	next unless $1;
	$ids{$1}++;
    }

    # need to explicitly close directory, or we get a file
    # handle leak on Solaris
    closedir(DIR);

    # since not all sessions have their own dbms now, find session ids in $Internal too
    if(my $internal = $self->{asp}{Internal}) {
	my $cached_keys = {};
	unless($cached_keys = $self->{asp}{internal_cached_keys}) {
	    map {
		if(/^([0-9a-f]{2})/) { 
		    $cached_keys->{$1}{$_}++
		}
	    } keys %$internal;
	    $self->{asp}{internal_cached_keys} = $cached_keys;
	}
	if(my $group_keys = $cached_keys->{$self->{group}}) {
	    %ids = ( %ids, %$group_keys );
	}
    }

    @ids = keys %ids;

    \@ids;
}

sub DefaultGroups {
    my $self = shift;
    my(@ids);
    local *STATEDIR;

    opendir(STATEDIR, $self->{state_dir}) 
	|| $self->{asp}->Error("can't open state dir $self->{state_dir}");
    my $time = time;
    for(readdir(STATEDIR)) {
	next if /^\./;
	next unless (length($_) eq $DefaultGroupIdLength);
	push(@ids, $_);
    }
    closedir STATEDIR;

    \@ids;
}

sub UmaskClear {
    my $self = shift;
    return if $self->{asp}{win32};
    $self->{umask_restore} = umask(0000);
}

sub UmaskRestore {
    my $self = shift;
    return if $self->{asp}{win32};
    if(defined $self->{umask_restore}) {
	umask($self->{umask_restore});
    }
}

sub DESTROY {
    my $self = shift;
    return unless %{$self};
    return if $self->{destroyed}++;
    $self->{dbm} && eval { $self->{dbm}->DESTROY };
    $self->{dbm} = undef;
}

# don't need to skip DESTROY since we have it defined
# return if ($AUTOLOAD =~ /DESTROY/);
sub AUTOLOAD {
    my $self = shift;
    my $AUTOLOAD = $Apache::ASP::State::AUTOLOAD;
    $AUTOLOAD =~ s/^(.*)::(.*?)$/$2/o;

    my $value;
    $value = $self->{dbm}->$AUTOLOAD(@_);

    $value;
}

sub TIEHASH {
    my $type = shift;

    # dual tie contructor, if we receive a State object to tie
    # then just return it, otherwise construct a new object
    # before tieing
    if((ref $_[0]) =~ /State/) {
	$_[0];
    } else {	
	bless &new(@_), $type;
    }
}

sub FETCH {
    my($self, $index) = @_;
    my $value;

    if($index eq '_FILE') {
	$value = $self->{file};
    } elsif($index eq '_SELF') {
	$value = $self;
    } else {
	$value = $self->{dbm}->FETCH($index);
	$self->{reads}++;
    }

    $value;
}

sub STORE {
    my $self = shift;

    # don't worry about overhead of Umask* routines, the STORE
    # being called is much heavier
    $self->UmaskClear;
    my $rv = $self->{dbm}->STORE(@_);
    $self->UmaskRestore;
    $self->{writes}++;

    $rv;
}

sub LOCK { my $self = tied(%{$_[0]}); $self->{dbm}->Lock(); }
sub UNLOCK { my $self = tied(%{$_[0]}); $self->{dbm}->UnLock(); }

1;