use strict;
use warnings;

############################################################################
#
#    NATHelper::Base
#    Helper class for NAT of RTP connections
#    - allocate sockets for rewriting SDP bodies
#    - transfer data between sockets within sessions
#    - close sessions
#    - expire sockets and sessions on inactivity
#
############################################################################

#
# ---------------- Base ------------------------------------------------
#  |       |       |       |     ...
#                        call-id
#                          |
#       ---------- Call's -----------------------------------
#       |       |       |           |   ...
#                     idfrom
#                       |
#       ---------------------------------------------
#       |       |       |           |   ...
#                      cseq
#                       |
#       -----------------
#        |     |        |
#        |     |  socket_group_from: SocketGroup
#        |     |
#        |   socket_groups_to
#        |     |
#        |     |- idto: SocketGroup
#        |     |- idto: SocketGroup
#        |     |- idto: SocketGroup
#        |     |- idto: SocketGroup
#        |     |...
#        |
#      sessions
#        |
#        |- idto: Session containing 2 x SocketGroup
#        |- idto: Session containing 2 x SocketGroup
#        |...
#


package Net::SIP::NATHelper::Base;
use fields qw( calls max_sockets max_sockets_in_group socket_count group_count  );

use Net::SIP::Util ':all';
use Net::SIP::Debug;
use List::Util qw( first sum );
use Time::HiRes 'gettimeofday';
use Errno 'EMFILE';
use Socket;

############################################################################
# create new Net::SIP::NATHelper::Base
# Args: ($class,%args);
# Returns: $self
############################################################################
sub new {
	my ($class,%args) = @_;
	# Hash of Net::SIP::NATHelper::Call indexed by call-id
	my $self = fields::new($class);
	%$self = (
		calls => {},
		socket_count => 0,
		group_count => 0,
		max_sockets => delete $args{max_sockets},
		max_sockets_in_group => delete $args{max_sockets_in_group},
	);
	return $self;
}


############################################################################
# allocate new sockets for RTP
#
# Args: ($self,$callid,$cseq,$idfrom,$idto,$side,$addr,\@media)
#   $callid: call-id
#   $cseq:   sequence number for cseq
#   $idfrom: ID for from-side
#   $idto:   ID for to-side
#   $side:   0 if SDP is from request, else 1
#   $addr:   IP where to create the new sockets
#   \@media: media like returned from Net::SIP::SDP::get_media
#
# Returns: $media
#   $media: \@list of [ip,base_port] of with the size of \@media
#
# Comment: if it fails () will be returned. In this cases the SIP packet
#  should not be forwarded (dropped) thus causing a retransmit (for UDP)
#  which will then cause another call to allocate_sockets and maybe this
#  time we have enough resources
############################################################################
sub allocate_sockets {
	my Net::SIP::NATHelper::Base $self = shift;
	my $callid = shift;

	my $call = $self->{calls}{$callid}
		||= Net::SIP::NATHelper::Call->new( $callid );
	return $call->allocate_sockets( $self,@_ );
}

############################################################################
# activate session
# Args: ($self,$callid,$cseq,$idfrom,$idto;$param)
#   $callid: call-id
#   $cseq:   sequence number for cseq
#   $idfrom: ID for from-side
#   $idto:   ID for to-side
#   $param:  user defined param which gets returned from info_as_hash
# Returns: ($info,$duplicate)
#   $info:  hash from sessions info_as_hash
#   $duplicate: TRUE if session was already created
# Comment: if it returns FALSE because it fails the SIP packet will not
#   be forwarded. This is the case on retransmits of really old SIP
#   packets where the session was already closed
############################################################################
sub activate_session {
	my Net::SIP::NATHelper::Base $self = shift;
	my $callid = shift;

	my $call = $self->{calls}{$callid};
	unless ( $call ) {
		DEBUG( 10,"tried to activate non-existing call $callid" );
		return;
	}
	return $call->activate_session( @_ );
}

############################################################################
# close session(s)
# Args: ($self,$callid,$cseq,$idfrom,$idto)
#   $callid: call-id
#   $cseq:   optional sequence number, only for CANCEL requests
#   $idfrom: ID for from-side
#   $idto:   ID for to-side
# Returns: @session_info
#   @session_info: list of hashes from session info_as_hash
# Comment: this SIP packet should be forwarded, even if the call
#  is not known here, because it did not receive the response from
#  the peer yet (e.g. was retransmit)
############################################################################
sub close_session {
	my Net::SIP::NATHelper::Base $self = shift;
	my $callid = shift;

	my $call = $self->{calls}{$callid};
	unless ( $call ) {
		DEBUG( 10,"tried to close non-existing call $callid" );
		return;
	}
	return $call->close_session( @_ );
}


############################################################################
# cleanup, e.g. delete expired sessions and unused socket groups
# Args: ($self,%args)
#  %args: hash with the following data
#    time:   current time, will get from gettimeofday() if not given
#    unused: seconds for timeout of sockets, which were never used in session
#       defaults to 3 minutes
#    active: seconds for timeout of sockets used in sessions, defaults to
#       30 seconds
# Returns: @expired
#   @expired: list of infos about expired sessions using sessions info_as_hash
############################################################################
sub expire {
	my Net::SIP::NATHelper::Base $self = shift;
	my %args = @_;

	$args{time}   ||= gettimeofday();
	$args{unused} ||= 3*60; # unused sockets after 3 minutes
	$args{active} ||= 30;   # active sessions after 30 seconds
	DEBUG( 100,"expire now=$args{time} unused=$args{unused} active=$args{active}" );
	my @expired;
	my $calls = $self->{calls};
	foreach my $callid ( keys %$calls ) {
		my $call = $calls->{$callid};
		push @expired, $call->expire( %args );
		if ( $call->is_empty ) {
			DEBUG( 50,"remove call $callid" );
			delete $calls->{$callid};
		}
	}
	return @expired;
}

############################################################################
# collect the callbacks for all sessions in all calls
# Args: $self
# Returns: @callbacks, see *::Session::callbacks
############################################################################
sub callbacks {
	my Net::SIP::NATHelper::Base $self = shift;
	return map { $_->callbacks } values %{ $self->{calls} };
}

############################################################################
# run over all sessions and execute callback
# Args: $self;$callback
#   $callback: callback, defaults to simply return the session
# Returns: @rv
#   @rv: array with the return values of all callbacks together
############################################################################
sub sessions {
	my Net::SIP::NATHelper::Base $self = shift;
	my $callback = shift;
	$callback ||= sub { return shift }; # default callback returns session
	return map { $_->sessions( $callback ) } values %{ $self->{calls} };
}

############################################################################
# Dump debug information into string
# Args: $self
# Returns: $string
############################################################################
sub dump {
	my Net::SIP::NATHelper::Base $self = shift;
	my $result = "";
	foreach ( values %{ $self->{calls} } ) {
		$result.= $_->dump;
	}
	return $result;
}

############################################################################
# return number of reserved calls
# Args: $self
# Returns: $n
############################################################################
sub number_of_calls {
	my Net::SIP::NATHelper::Base $self = shift;
	return scalar( keys %{ $self->{calls} })
}

############################################################################
# get RTP sockets
# can be redefined to allow enforcing of resource limits, caching of
# sockets...
# right now creates fresh RTP sockets unless max_sockets is reached,
# in which case it returns () with $! set to EMFILE
# Args: ($self,$new_addr,$media)
#    $new_addr: IP for new sockets
#    $media: old media like given from Net::SIP::SDP::get_media
# Returns: \@new_media
#    @new_media: list of [ addr,base_port,\@socks,\@targets]
#      where addr and base_port are the address and base port for the new
#      media, @socks the list of sockets and @targets the matching targets
#      based on the original media
############################################################################
sub get_rtp_sockets {
	my Net::SIP::NATHelper::Base $self = shift;
	my ($new_addr,$media) = @_;
	my @new_media;

	my $need_sockets = sum( map { $_->{range} } @$media );
	if ( my $max = $self->{max_sockets_in_group} ) {
		if ( $need_sockets > $max ) {
			DEBUG( 1,"allocation of RTP sockets denied because max_sockets_in_group limit reached" );
			$! = EMFILE;
			return;
		}
	}

	if ( my $max = $self->{max_sockets} ) {
		if ( $self->{socket_count} + $need_sockets > $max ) {
			DEBUG( 1,"allocation of RTP sockets denied because max_sockets limit reached" );
			$! = EMFILE;
			return;
		}
	}

	foreach my $m (@$media) {
		my ($addr,$port,$range) = @{$m}{qw/addr port range/};

		# allocate new sockets
		my ($new_port,@socks) = create_rtp_sockets( $new_addr,$range );
		unless (@socks) {
			DEBUG( 1,"allocation of RTP sockets failed: $!" );
			return;
		}

		# determine target for sock, e.g. original address
		my $addr_bin = inet_aton($addr);
		my @targets;
		for( my $i=0;$i<@socks;$i++ ) {
			my $dst = sockaddr_in( $port+$i,$addr_bin );
			push @targets,$dst;
		}

		DEBUG( 100,"m_old=$addr $port/$range new_port=$new_port" );
		push @new_media, [ $new_addr,$new_port,\@socks,\@targets ];
	}

	$self->{socket_count} += $need_sockets;
	$self->{group_count} ++;

	return \@new_media;
}

############################################################################
# free created RTP sockets
# Args: $self,$media
#   $media: see return code from get_rtp_sockets
# Returns: NONE
############################################################################
sub unget_rtp_sockets {
	my Net::SIP::NATHelper::Base $self = shift;
	my $media = shift;
	$self->{group_count} --;
	$self->{socket_count} -= sum( map { int(@{ $_->[2] }) } @$media );
}

############################################################################
############################################################################
#
# Net::SIP::NATHelper::Call
# manages Call, e.g. for each active cseq for the same call-id
# it manages the Net::SIP::NATHelper::SocketGroup's and Net::SIP::NATHelper::Session's
#
############################################################################
############################################################################

package Net::SIP::NATHelper::Call;
use fields qw( callid from );
use Hash::Util 'lock_keys';
use List::Util 'max';
use Net::SIP::Debug;
use Net::SIP::Util 'invoke_callback';

sub new {
	my ($class,$callid) = @_;
	my $self = fields::new($class);
	%$self = (
		callid => $callid,
		from => {},
	);
	return $self;
}

############################################################################
# allocate sockets for rewriting SDP body
# Args: ($nathelper,$self,$cseq,$idfrom,$idto,$side,$addr,$media)
# Returns: $media
############################################################################
sub allocate_sockets {
	my Net::SIP::NATHelper::Call $self = shift;
	my ($nathelper,$cseq,$idfrom,$idto,$side,$addr,$media) = @_;

	# find existing data for $idfrom,$cseq
	my $cseq_data = $self->{from}{$idfrom};
	my $data = $cseq_data && $cseq_data->{$cseq};

	if ( ! $data ) {
		# if it is not known check if cseq is too small (retransmit of old packet)
		if ( $cseq_data ) {
			foreach ( keys %$cseq_data ) {
				if ( $_ > $cseq ) {
					DEBUG( 10,"retransmit? cseq $cseq is smaller than $_ in call $self->{callid}" );
					return;
				}
			}
		}

		# need new record
		$cseq_data ||= $self->{from}{$idfrom} = {};
		$data = $cseq_data->{$cseq} = {
			socket_group_from => undef,
			socket_groups_to  => {},    # indexed by idto
			sessions          => {},    # indexed by idto
		};
		lock_keys( %$data );
	}

	# if SocketGroup already exists return it's media
	# otherwise try to create a new one
	# if this fails return (), otherwise return media

	my $sgroup;
	if ( $side == 0 ) { # FROM
		$sgroup = $data->{socket_group_from} ||= do {
			DEBUG( 10,"new socketgroup with idfrom $idfrom" );
			Net::SIP::NATHelper::SocketGroup->new( $nathelper,$idfrom,$addr,$media )
				|| return;
		};
	} else {
		$sgroup = $data->{socket_groups_to}{$idto} ||= do {
			DEBUG( 10,"new socketgroup with idto $idto" );
			Net::SIP::NATHelper::SocketGroup->new( $nathelper,$idto,$addr,$media )
				|| return;
		};
	}

	return $sgroup->get_media;
}

############################################################################
# activate session
# Args: ($self,$cseq,$idfrom,$idto;$param)
# Returns: ($info,$duplicate)
############################################################################
sub activate_session {
	my Net::SIP::NATHelper::Call $self = shift;
	my ($cseq,$idfrom,$idto,$param) = @_;

	my $by_cseq = $self->{from}{$idfrom};
	my $data = $by_cseq && $by_cseq->{$cseq};
	unless ( $data ) {
		DEBUG( 10,"tried to activate non-existing session $idfrom|$cseq in call $self->{callid}" );
		return;
	}

	my $sessions = $data->{sessions};
	if ( my $sess = $sessions->{$idto} ) {
		# exists already, maybe retransmit of ACK
		return ( $sess->info_as_hash( $self->{callid},$cseq ), 1 );
	}

	my $gfrom = $data->{socket_group_from};
	my $gto   = $data->{socket_groups_to}{$idto};
	if ( !$gfrom || !$gto ) {
		DEBUG( 50,"session $self->{callid},$cseq $idfrom -> $idto not complete " );
		return;
	}

	my $sess = $sessions->{$idto} =
		Net::SIP::NATHelper::Session->new( $gfrom,$gto,$param );
	DEBUG( 10,"new session {$sess->{id}} $self->{callid},$cseq $idfrom -> $idto" );

	return ( $sess->info_as_hash( $self->{callid},$cseq ), 0 );
}

############################################################################
# close session
# Args: ($self,$cseq,$idfrom,$idto)
#   $cseq: optional sequence number, only for CANCEL requests
# Returns: @session_info
#   @session_info: list of infos of all closed sessions, info is hash with
#     callid,cseq,idfrom,idto,from,to,bytes_from,bytes_to
############################################################################
sub close_session {
	my Net::SIP::NATHelper::Call $self = shift;
	my ($cseq,$idfrom,$idto) = @_;

	#DEBUG( 100,$self->dump );

	my @info;
	if ( $cseq ) {
		# close initiated by CANCEL
		my $data = $self->{from}{$idfrom};
		$data = $data && $data->{$cseq};
		my $sess = $data && delete( $data->{sessions}{$idto} ) or do {
			DEBUG( 10,"tried to CANCEL non existing session in $self->{callid}|$cseq" );
			return;
		};
		push @info, $sess->info_as_hash( $self->{callid},$cseq );
		DEBUG( 10,"close session {$sess->{id}} $self->{callid}|$cseq $idto,$idfrom success" );

	} else {
		# close from BYE (which has different cseq then the INVITE)
		# need to close all sessions between idfrom and idto, because BYE could
		# originate by UAC or UAS
		foreach my $pair ( [ $idfrom,$idto ],[ $idto,$idfrom ] ) {
			my ($from,$to) = @$pair;
			my $by_cseq = $self->{from}{$from} || next;

			foreach my $cseq ( keys %$by_cseq ) {
				my $sess = delete $by_cseq->{$cseq}{sessions}{$to} || next;
				push @info, $sess->info_as_hash( $self->{callid},$cseq );
				DEBUG( 10,"close session {$sess->{id}} $self->{callid}|$cseq $idto,$idfrom " );
			}
		}
		unless (@info) {
			DEBUG( 10,"tried to BYE non existing session in $self->{callid}" );
			return;
		}
		DEBUG( 10,"close sessions $self->{callid} $idto,$idfrom success" );
	}
	return @info;
}

############################################################################
# expire call, e.g. inactive sessions, unused socketgroups...
# Args: ($self,%args)
#   %args: see *::Base::expire
# Returns: @expired
#   @expired: list of infos about expired sessions containing, see
#      close_session
############################################################################
sub expire {
	my Net::SIP::NATHelper::Call $self = shift;
	my %args = @_;

	my $expire_unused = $args{time} - $args{unused};
	my $expire_active = $args{time} - $args{active};

	my @expired;
	my %active_pairs; # mapping [idfrom,idto]|[idto,idfrom] -> session.created
	my $need_next_pass;
	my $by_from = $self->{from};

	for my $pass (1,2) {
		while ( my ($idfrom,$by_cseq) = each %$by_from ) {

			# start with highest cseq so that we hopefully need 2 passes
			# for expire session which got replaced by new ones
			my @cseq = sort { $b <=> $a } keys %$by_cseq;
			foreach my $cseq ( @cseq ) {
				my $data = $by_cseq->{$cseq};

				# drop inactive sessions
				my $sessions = $data->{sessions};
				foreach my $idto ( keys %$sessions ) {
					my $sess = $sessions->{$idto};
					my $lastmod = max($sess->lastmod,$sess->{created});
					if ( $lastmod < $expire_active ) {
						DEBUG( 10,"expired session {$sess->{id}} $cseq|$idfrom|$idto because lastmod($lastmod) < active($expire_active)" );
						my $sess = delete $sessions->{$idto};
						push @expired, $sess->info_as_hash( $self->{callid}, $cseq, reason => 'expired' );

					} elsif ( my $created = max(
						$active_pairs{ "$idfrom\0$idto" } || 0,
						$active_pairs{ "$idto\0$idfrom" } || 0
						) ) {
						if ( $created > $sess->{created} ) {
							DEBUG( 10,"removed session {$sess->{id}} $cseq|$idfrom|$idto because there is newer session" );
							my $sess = delete $sessions->{$idto};
							push @expired, $sess->info_as_hash( $self->{callid}, $cseq, reason => 'replaced' );
						} elsif ( $created < $sess->{created} ) {
							# probably a session in the other direction has started
							DEBUG( 100,"there is another session with created=$created which should be removed in next pass" );
							$active_pairs{ "$idfrom\0$idto" } = $sess->{created};
							$need_next_pass = 1
						}
					} else {
						# keep session
						DEBUG( 100,"session {$sess->{id}} $idfrom -> $idto created=$sess->{created} stays active in pass#$pass" );
						$active_pairs{ "$idfrom\0$idto" } = $sess->{created};
					}
				}

				# delete socketgroups, which are not used in sessions and which
				# are expired
				# use string representation as key for comparison
				my %used;
				foreach ( values %$sessions ) {
					$used{ $_->{sfrom} }++;
					$used{ $_->{sto} }++;
				}

				my $groups = $data->{socket_groups_to};
				my %expired_sg;
				my @v = values(%$groups);
				push @v,$data->{socket_group_from} if $data->{socket_group_from};
				foreach my $v ( @v ) {
					next if $used{ $v }; # used in not expired session
					my $lastmod = $v->{lastmod};
					if ( ! $lastmod ) {
						# was never used
						if ( $v->{created} < $expire_unused ) {
							DEBUG( 10,"expired socketgroup $v->{id} because created($v->{created}) < unused($expire_unused)" );
							$expired_sg{$v} = 1;
						}
					} elsif ( $lastmod < $expire_active ) {
						DEBUG( 10,"expired socketgroup $v->{id} because lastmod($lastmod) < active($expire_active)" );
						$expired_sg{$v} = 1;
					}
				}

				$data->{socket_group_from} = undef if %expired_sg
					and delete( $expired_sg{ $data->{socket_group_from} } );
				if ( %expired_sg ) {
					foreach my $id (keys(%$groups)) {
						delete $groups->{$id} if delete $expired_sg{$groups->{$id}};
						%expired_sg || last;
					}
				}
			}
		}

		# only run again if needed
		$need_next_pass || last;
		$need_next_pass = 0;
		DEBUG( 100,'need another pass' );
	}
	return @expired;
}

############################################################################
# check if empty, e.g. no more socket groups on the call
# Args: $self
# Returns: TRUE if empty
############################################################################
sub is_empty {
	my Net::SIP::NATHelper::Call $self = shift;
	my $by_from = $self->{from};
	foreach my $idfrom ( keys %$by_from ) {
		my $by_cseq = $by_from->{$idfrom};
		foreach my $cseq ( keys %$by_cseq ) {
			my $data = $by_cseq->{$cseq};
			if ( ! %{ $data->{socket_groups_to}} && ! $data->{socket_group_from} ) {
				DEBUG( 100,"deleted unused cseq $cseq in $self->{callid}|$idfrom" );
				delete $by_cseq->{$cseq};
			}
		}
		if ( ! %$by_cseq ) {
			DEBUG( 100,"deleted unused idfrom $idfrom in $self->{callid}" );
			delete $by_from->{$idfrom};
		}
	}
	return %$by_from ? 0:1;
}

############################################################################
# collect the callbacks for all sessions within the call
# Args: $self
# Returns: @callbacks, see Net::SIP::NATHelper::Session::callbacks
############################################################################
sub callbacks {
	my Net::SIP::NATHelper::Call $self = shift;
	my @cb;
	my $by_from = $self->{from};
	foreach my $by_cseq ( values %$by_from ) {
		foreach my $data ( values %$by_cseq ) {
			push @cb, map { $_->callbacks } values %{ $data->{sessions} };
		}
	}
	return @cb;
}

############################################################################
# run over all session and execte callback
# Args: $self,$callback
# Returns: @rv
#  @rv: results of all callback invocations together
############################################################################
sub sessions {
	my Net::SIP::NATHelper::Call $self = shift;
	my $callback = shift;
	my $by_from = $self->{from};
	my @rv;
	foreach my $by_cseq ( values %$by_from ) {
		foreach my $data ( values %$by_cseq ) {
			push @rv, map { invoke_callback($callback,$data) }
				values %{ $data->{sessions} };
		}
	}
	return @rv;
}

############################################################################
# Dump debug information into string
# Args: $self
# Returns: $string
############################################################################
sub dump {
	my Net::SIP::NATHelper::Call $self = shift;
	my $result = "-- DUMP of call $self->{callid} --\n";
	my $by_from = $self->{from};
	foreach my $idfrom ( sort keys %$by_from ) {
		my $by_cseq = $by_from->{$idfrom};
		foreach ( sort { $a <=> $b } keys %$by_cseq ) {
			$result.= "-- Socket groups in $idfrom|$_ --\n";
			my $sgroups = $by_cseq->{$_}{socket_groups_to};
			my $sf = $by_cseq->{$_}{socket_group_from};
			$result .= $sf->dump if $sf;
			foreach ( sort keys %$sgroups ) {
				$result.= $sgroups->{$_}->dump;
			}
			$result.= "-- Sessions in $idfrom|$_ --\n";
			my $sessions = $by_cseq->{$_}{sessions};
			foreach ( sort keys %$sessions ) {
				$result.= $sessions->{$_}->dump;
			}
		}
	}
	return $result;
}


############################################################################
############################################################################
#
# Net::SIP::NATHelper::Session
# each session consists of two Net::SIP::NATHelper::SocketGroup's and the data
# are transferred between these groups
#
############################################################################
############################################################################

package Net::SIP::NATHelper::Session;
use fields qw( sfrom sto created bytes_from bytes_to callbacks id param );
use Net::SIP::Debug;
use List::Util 'max';
use Time::HiRes 'gettimeofday';

# increased for each new session
my $session_id = 0;

############################################################################
# create new Session between two SocketGroup's
# Args: ($class,$socketgroup_from,$socketgroup_to;$param)
# Returns: $self
############################################################################
sub new {
	my ($class,$sfrom,$sto,$param) = @_;
	my $self = fields::new( $class );

	# sanity check that both use the same number of sockets
	if ( @{ $sfrom->get_socks } != @{ $sto->get_socks } ) {
		DEBUG( 1,"different number of sockets in request and response" );
		return;
	}

	%$self = (
		sfrom => $sfrom,
		sto => $sto,
		created => scalar( gettimeofday() ),
		bytes_from => 0,
		bytes_to => 0,
		callbacks => undef,
		param => $param,
		id => ++$session_id,
	);
	return $self;
}

############################################################################
# returns session info as hash
# Args: ($self,$callid,$cseq,%more)
#   %more: hash with more key,values to put into info
# Returns: %session_info
#   %session_info: hash with callid,cseq,idfrom,idto,from,to,
#      bytes_from,bytes_to,sessionid and %more
############################################################################
sub info_as_hash {
	my Net::SIP::NATHelper::Session $self = shift;
	my ($callid,$cseq,%more) = @_;

	my $from = join( ",", map {
		"$_->{addr}:$_->{port}/$_->{range}"
	} @{ $self->{sfrom}{orig_media} } );

	my $to = join( ",", map {
		"$_->{addr}:$_->{port}/$_->{range}"
	} @{ $self->{sto}{orig_media} } );

	return {
		callid => $callid,
		cseq   => $cseq,
		idfrom => $self->{sfrom}{id},
		idto   => $self->{sto}{id},
		from   => $from,
		to     => $to,
		bytes_from => $self->{bytes_from},
		bytes_to => $self->{bytes_to},
		created => $self->{created},
		sessionid => $self->{id},
		param => $self->{param},
		%more,
	}
}

############################################################################
# return time of last modification, e.g. maximum of lastmod of both
# socketgroups
# Args: $self
# Returns: $lastmod
############################################################################
sub lastmod {
	my Net::SIP::NATHelper::Session $self = shift;
	return max( $self->{sfrom}{lastmod}, $self->{sto}{lastmod} );
}

############################################################################
# return all [ socket, callback,cbid ] tuples for the session
# cbid is uniq for each callback and can be used to detect, which callbacks
# changed compared to the last call
# Args: $self
# Returns: @callbacks
############################################################################

my $callback_id = 0; # uniq id for each callback
sub callbacks {
	my Net::SIP::NATHelper::Session $self = shift;

	my $callbacks = $self->{callbacks};
	return @$callbacks if $callbacks; # already computed

	# data received on sockets in $sfrom will be forwarded to the original
	# target from $sfrom using the matching socket from $sto and the other
	# way around.
	# This means we do symetric RTP in all cases

	my $sfrom        = $self->{sfrom};
	my $sockets_from = $sfrom->get_socks;
	my $targets_from = $sfrom->get_targets;

	my $sto          = $self->{sto};
	my $sockets_to   = $sto->get_socks;
	my $targets_to   = $sto->get_targets;

	my @cb;
	for( my $i=0;$i<@$sockets_from;$i++ ) {
		push @cb, [
			$sockets_from->[$i],
			[
				\&_forward_data,
				$sockets_from->[$i],   # read data from socket FROM(nat)
				$sockets_to->[$i],     # forward data using socket TO(nat)
				$targets_from->[$i],   # to FROM(original)
				$sfrom,                # call $sfrom->didit
				\$self->{bytes_to},    # to count bytes coming from 'to'
				$self->{id},           # for debug messages
			],
			++$callback_id
		];

		push @cb, [
			$sockets_to->[$i],
			[
				\&_forward_data,
				$sockets_to->[$i],     # read data from socket TO(nat)
				$sockets_from->[$i],   # forward data using socket FROM(nat)
				$targets_to->[$i],     # to TO(original)
				$sto,                  # call $sto->didit
				\$self->{bytes_from},  # to count bytes coming from 'from'
				$self->{id},           # for debug messages
			],
			++$callback_id
		];
	}
	$self->{callbacks} = \@cb; # cache
	return @cb;
}

############################################################################
# internal function used for forwarding data in callbacks()
############################################################################
sub _forward_data {
	my ($read_socket,$write_socket,$dstaddr,$group,$bytes,$id) = @_;
	recv( $read_socket, my $buf,2**16,0 ) || do {
		DEBUG( 10,"recv data failed: $!" );
		return;
	};

	my $l = length($buf);
	$$bytes += $l;
	$group->didit($l);

	send( $write_socket, $buf,0,$dstaddr ) || do {
		DEBUG( 10,"send data failed: $!" );
		return;
	};
	my $name = sub {
		my $bin = shift;
		use Socket;
		my ($port,$addr) = unpack_sockaddr_in( $bin );
		return inet_ntoa($addr).':'.$port;
	};
	DEBUG( 50,"{$id} transferred %d bytes on %s via %s to %s",
		length($buf), $name->( getsockname($read_socket )),
		$name->(getsockname( $write_socket )),$name->($dstaddr));
}


############################################################################
# Dump debug information into string
# Args: $self
# Returns: $string
############################################################################
sub dump {
	my Net::SIP::NATHelper::Session $self = shift;
	return "{$self->{id}}".
		( $self->{sfrom} && $self->{sfrom}{id} || 'NO.SFROM' ).",".
		( $self->{sto} && $self->{sto}{id} || 'NO.STO' )."\n";
}

############################################################################
############################################################################
#
# Net::SIP::NATHelper::SocketGroup
# manages groups of sockets created from an SDP body
# manages the local (NAT) sockets and the original targets from the SDP
#
############################################################################
############################################################################

package Net::SIP::NATHelper::SocketGroup;
use fields qw( id created lastmod new_media orig_media nathelper );
use Net::SIP::Debug;
use Time::HiRes 'gettimeofday';
use Socket;

############################################################################
# create new socket group based on the original media and a local address
# Args: ($class,$nathelper,$id,$new_addr,$media)
# Returns: $self|()
# Comment: () will be returned if allocation of sockets fails
############################################################################
sub new {
	my ($class,$nathelper,$id,$new_addr,$media) = @_;
	my $new_media = $nathelper->get_rtp_sockets( $new_addr,$media )
		or return;

	my $self = fields::new($class);
	%$self = (
		nathelper => $nathelper,
		id => $id,
		orig_media => [ @$media ],
		new_media => $new_media,
		lastmod => 0,
		created => scalar( gettimeofday() ),
	);
	return $self;
}

############################################################################
# give allocated sockets back to NATHelper
############################################################################
sub DESTROY {
	my Net::SIP::NATHelper::SocketGroup $self = shift;
	($self->{nathelper} || return )->unget_rtp_sockets( $self->{new_media} )
}


############################################################################
# updates timestamp of last modification, used in expiring
# Args: ($self)
# Returns: NONE
############################################################################
sub didit {
	my Net::SIP::NATHelper::SocketGroup $self = shift;
	$self->{lastmod} = gettimeofday();
}

############################################################################
# returns \@list of media [ip,port,range] in group
# Args: $self
# Returns: \@media
############################################################################
sub get_media {
	my Net::SIP::NATHelper::SocketGroup $self = shift;
	my @media = map { [
		$_->[0],           # addr
		$_->[1],           # base port
		int(@{$_->[2]})    # range, e.g number of sockets
	]} @{ $self->{new_media} };
	return \@media;
}

############################################################################
# returns \@list of sockets in group
# Args: $self
# Returns: \@sockets
############################################################################
sub get_socks {
	my Net::SIP::NATHelper::SocketGroup $self = shift;
	return [ map { @{$_->[2]} } @{$self->{new_media}} ];
}

############################################################################
# returns \@list of the original targets in group
# Args: $self
# Returns: \@targets
############################################################################
sub get_targets {
	my Net::SIP::NATHelper::SocketGroup $self = shift;
	return [ map { @{$_->[3]} } @{$self->{new_media}} ];
}

############################################################################
# Dump debug information into string
# Args: $self
# Returns: $string
############################################################################
sub dump {
	my Net::SIP::NATHelper::SocketGroup $self = shift;
	my $result = $self->{id}." >> ".join( ' ',
		map { "$_->[0]:$_->[1]/$_->[2]" }
		@{$self->get_media} ).
		"\n";
	return $result;
}

1;
