#!/usr/bin/perl
#
# DW::Request::RateLimit
#
# Module to handle rate limiting for the site using a leaky bucket algorithm.
#
# Authors:
#      Mark Smith <mark@dreamwidth.org>
#
# Copyright (c) 2025 by Dreamwidth Studios, LLC.
#
# This program is free software; you may redistribute it and/or modify it under
# the same terms as Perl itself.  For a copy of the license, please reference
# 'perldoc perlartistic' or 'perldoc perlgpl'.
#

package DW::RateLimit;

use strict;
use v5.10;
use Log::Log4perl;
my $log = Log::Log4perl->get_logger(__PACKAGE__);

use LJ::MemCache;
use LJ::User;
use Time::HiRes qw(time);

# Class to handle rate limiting
package DW::RateLimit::Limit;

use strict;
use warnings;

sub new {
    my ( $class, %opts ) = @_;
    my $self = bless {
        name          => $opts{name},
        max_count     => $opts{max_count},
        interval_secs => $opts{per_interval_secs},
        key_prefix    => "ratelimit:",
        mode          => $opts{mode} || 'block',

        # Calculate refill rate (tokens per second)
        refill_rate => $opts{max_count} / $opts{per_interval_secs},
    }, $class;
    return $self;
}

# Check the rate limit status
# Returns a hash containing:
#   exceeded: 1 if they have exceeded the limit, 0 if they haven't
#   time_remaining: seconds until the rate limit resets (0 if not exceeded)
#   count: current count of requests
sub check {
    my ( $self, %opts ) = @_;

    # Handle ignore mode - always return not exceeded
    return { exceeded => 0, time_remaining => 0, count => 0 } if $self->{mode} eq 'ignore';

    # Get the key to use for this rate limit
    my $key = $self->_get_key(%opts);
    return { exceeded => 0, time_remaining => 0, count => 0 } unless $key;

    # Get the current state from memcache
    my $state_str = LJ::MemCache::get($key);
    my ( $level, $last_update );
    if ($state_str) {
        ( $level, $last_update ) = split( ':', $state_str );
    }
    else {
        $level       = $self->{max_count};
        $last_update = time();
    }

    # Calculate time elapsed since last update
    my $now     = time();
    my $elapsed = $now - $last_update;

    # Calculate new bucket level after refill
    my $new_level = $level + ( $elapsed * $self->{refill_rate} );
    $new_level = $self->{max_count} if $new_level > $self->{max_count};

    # Calculate current count (tokens used)
    my $count = $self->{max_count} - $new_level;

    # If we're at or over the limit
    if ( $new_level < 1 ) {

        # Log if in log mode
        if ( $self->{mode} eq 'log' ) {
            $log->info("RateLimit: Exceeded limit on $key");
            return { exceeded => 0, time_remaining => 0, count => $count };
        }

        # Calculate time remaining until reset
        # When bucket is empty, time remaining is the full interval
        my $time_remaining = $self->{interval_secs};
        return { exceeded => 1, time_remaining => $time_remaining, count => $count };
    }

    # Decrement the counter and update timestamp
    $new_level -= 1;
    my $new_state_str = "$new_level:$now";

    # Store the new state
    LJ::MemCache::set( $key, $new_state_str, $self->{interval_secs} );

    # Return success with no time remaining
    return { exceeded => 0, time_remaining => 0, count => $count + 1 };
}

# Reset the counter for this rate limit
sub reset {
    my ( $self, %opts ) = @_;

    # In ignore mode, do nothing
    return 1 if $self->{mode} eq 'ignore';

    my $key = $self->_get_key(%opts);
    return 0 unless $key;

    # Set the state to full bucket and current time
    my $new_state_str = "$self->{max_count}:" . time();

    # Store the new state with the full interval
    LJ::MemCache::set( $key, $new_state_str, $self->{interval_secs} );
    return 1;
}

# Internal method to generate the memcache key
sub _get_key {
    my ( $self, %opts ) = @_;

    my @key_parts = ( $self->{key_prefix}, $self->{name} );

    # Add userid if provided
    if ( my $userid = $opts{userid} ) {
        push @key_parts, "user:$userid";
    }

    # Add IP if provided
    if ( my $ip = $opts{ip} ) {
        push @key_parts, "ip:$ip";
    }

    # Add any additional identifiers
    if ( my $identifiers = $opts{identifiers} ) {
        foreach my $id ( sort keys %$identifiers ) {
            push @key_parts, "$id:$identifiers->{$id}";
        }
    }

    return join( ":", @key_parts );
}

# Package methods for DW::RateLimit
package DW::RateLimit;

use strict;
use warnings;

# Parse a rate limit string in the format "count/interval[unit]"
# Examples: "1/5s", "10/1m", "100/1h", "1000/1d"
sub _parse_rate_string {
    my ( $class, $rate_string ) = @_;
    return undef unless $rate_string && $rate_string =~ /^(\d+)\/(\d+)([smhd])$/;

    my ( $max_count, $interval, $unit ) = ( $1, $2, $3 );

    # Convert interval to seconds based on unit
    my $per_interval_secs = $interval;
    $per_interval_secs *= 60    if $unit eq 'm';
    $per_interval_secs *= 3600  if $unit eq 'h';
    $per_interval_secs *= 86400 if $unit eq 'd';

    return {
        max_count         => $max_count,
        per_interval_secs => $per_interval_secs
    };
}

# Get a rate limit object
sub get {
    my ( $class, $name, %opts ) = @_;

    # Validate required parameters
    return undef unless $name && $opts{rate};

    # Parse the rate string
    my $parsed = $class->_parse_rate_string( $opts{rate} );
    return undef unless $parsed;

    # Check for configuration overrides
    if ( $LJ::RATE_LIMITS{$name} ) {
        my $config = $LJ::RATE_LIMITS{$name};

        # Handle rate string in config
        if ( $config->{rate} ) {
            my $config_parsed = $class->_parse_rate_string( $config->{rate} );
            if ($config_parsed) {
                $parsed = $config_parsed;
            }
        }
        $opts{mode} = $config->{mode} if defined $config->{mode};
    }

    return DW::RateLimit::Limit->new(
        name              => $name,
        max_count         => $parsed->{max_count},
        per_interval_secs => $parsed->{per_interval_secs},
        mode              => $opts{mode},
    );
}

1;
