package Whostmgr::CometBackup::Scheduling;

#                                      Copyright 2025 WebPros International, LLC
#                                                           All rights reserved.
# copyright@cpanel.net                                         http://cpanel.net
# This code is subject to the cPanel license. Unauthorized copying is prohibited.

use cPstrict;

use Time::Piece   ();
use Time::Seconds ();
use Whostmgr::CometBackup::Logger;
my $logger = Whostmgr::CometBackup::Logger->new();

=encoding utf-8

=head1 NAME

Whostmgr::CometBackup::Scheduling - Backup job scheduling system for Comet backup integration

=head1 SYNOPSIS

    use Whostmgr::CometBackup::Scheduling ();
    
    my $scheduler = Whostmgr::CometBackup::Scheduling->new();
    
    # Calculate next run time for a backup job
    my $time_until_next_run = $scheduler->next_run_at($job_uuid);
    print "Next backup runs in: $time_until_next_run\n";
    
    # Calculate next run time from schedule info directly
    my $schedule_info = {
        frequency => 'daily',
        when      => [1, 2, 3, 4, 5],  # weekdays (numeric)
        time      => '02:00',          # 2 AM
    };
    
    # Or using day names
    my $schedule_with_names = {
        frequency => 'weekly',
        when      => ['monday', 'wednesday', 'friday'],  # day names
        time      => '02:00',
    };
    my $next_run = $scheduler->calculate_next_run($schedule_info);
    print "Next run: $next_run\n";

=head1 DESCRIPTION

This module provides scheduling calculations for Comet backup jobs. It can determine
when the next backup job should run based on schedule configuration and the current
server time. The module supports various scheduling patterns including daily,
weekly, and monthly schedules with specific time and day constraints.

The module uses Time::Piece for date calculations and returns human-readable
time differences in hours (for periods less than 24 hours) or days (for periods
24 hours or longer).

=cut

=head1 METHODS

=head2 new

    my $scheduler = Whostmgr::CometBackup::Scheduling->new();

Creates a new Scheduling instance.

=cut

sub new ($class) {
    my $self = bless {}, $class;
    return $self;
}

=head2 next_run_at

    my $next_run_timestamp = $scheduler->next_run_at($job_data);

Calculates when the next run will occur for a specific backup job.

Parameters:
- job_data: Hash reference containing job information with schedule_type, schedule_info,
           and optionally last_run_timestamp for 24-hour cooldown enforcement

Returns: Unix timestamp of the next run time
         Returns undef if job is manual/inactive or invalid

=cut

sub next_run_at ( $self, $job_data ) {
    $logger->info( "Calculating next run for job", { job_data => $job_data } );
    unless ( $job_data && ref $job_data eq 'HASH' ) {
        return;
    }

    # Only scheduled jobs have next run times
    return unless $job_data->{schedule_type} eq 'scheduled';
    return unless $job_data->{schedule_info};
    return $self->calculate_next_run( $job_data->{schedule_info}, $job_data->{last_run_timestamp} );
}

=head2 calculate_next_run

    my $next_run_timestamp = $scheduler->calculate_next_run($schedule_info, $last_run_timestamp);

Calculates the next run time based on schedule configuration and current server time.
Enforces a 24-hour cooldown period from the last run if provided.

Parameters:
- schedule_info: Hash reference containing schedule configuration
  - frequency: 'daily', 'weekly', or 'monthly'
  - when: Array of days (1-7 for weekly, 1-31 for monthly, 1-7 for daily weekdays)
         Can also use English day names: 'monday', 'tuesday', etc. or abbreviations: 'mon', 'tue', etc.
  - time: Time in HH:MM format (defaults to '02:00')
- last_run_timestamp: Optional Unix timestamp of the last backup run. If provided, enforces
                     a 24-hour minimum interval between runs.

Returns: Unix timestamp of next run time or undef if invalid schedule

=cut

sub calculate_next_run ( $self, $schedule_info, $last_run_timestamp = undef ) {
    unless ( $schedule_info && ref $schedule_info eq 'HASH' ) {
        return;
    }

    my $frequency = $schedule_info->{frequency} || '';
    my $when      = $schedule_info->{when}      || [];
    my $time      = $schedule_info->{time}      || '02:00';

    # Parse the time (HH:MM format)
    my ( $hour, $minute ) = split /:/, $time;
    $hour   ||= 2;
    $minute ||= 0;

    # Validate time components
    return unless $hour   =~ /^\d{1,2}$/ && $hour >= 0   && $hour <= 23;
    return unless $minute =~ /^\d{1,2}$/ && $minute >= 0 && $minute <= 59;

    my $now = Time::Piece->new();

    # Determine if this job has never been run (no last_run_timestamp or it's 0)
    my $never_run = ( !defined $last_run_timestamp || $last_run_timestamp == 0 ) ? 1 : 0;

    # Calculate earliest possible next run considering 24-hour cooldown
    my $earliest_next_run = $now;
    if ( defined $last_run_timestamp && $last_run_timestamp > 0 ) {

        # Enforce 24-hour cooldown: next run cannot be sooner than 24 hours after last run
        my $cooldown_end = $last_run_timestamp + 86400;    # 86400 seconds = 24 hours
        if ( $cooldown_end > $now->epoch ) {
            $earliest_next_run = Time::Piece->new($cooldown_end);
            $logger->debug(
                "Enforcing 24-hour cooldown period",
                {
                    last_run_timestamp => $last_run_timestamp,
                    cooldown_ends_at   => $cooldown_end,
                    now                => $now->epoch,
                }
            );
        }
    }

    my $next_run_time;

    if ( $frequency eq 'daily' ) {
        $next_run_time = $self->_calculate_daily_next_run( $earliest_next_run, $when, $hour, $minute, $never_run );
        $logger->debug( "NEXT RUN TIME DAILY: " . ( $next_run_time ? $next_run_time->datetime : 'undef' ) );
    }
    elsif ( $frequency eq 'weekly' ) {
        $next_run_time = $self->_calculate_weekly_next_run( $earliest_next_run, $when, $hour, $minute, $never_run );
    }
    elsif ( $frequency eq 'monthly' ) {
        $next_run_time = $self->_calculate_monthly_next_run( $earliest_next_run, $when, $hour, $minute, $never_run );
    }
    else {
        return;
    }

    return unless $next_run_time;

    # Return the Unix timestamp of the next run time
    return $next_run_time->epoch;
}

# Private methods

sub _calculate_daily_next_run ( $self, $now, $when, $hour, $minute, $never_run = 0 ) {

    # For daily schedules, 'when' can specify weekdays (1=Monday, 7=Sunday)
    # If 'when' is empty, run every day
    # $now may be adjusted for 24-hour cooldown, so use it as the reference point

    my $reference_day = $now;

    # Try the reference day (could be today or a future day if cooldown is active)
    my $reference_run_time = $reference_day->truncate( to => 'day' ) + ( $hour * 3600 ) + ( $minute * 60 );

    # Special handling for jobs that have never run: allow them to run on the current day
    # even if the scheduled time has passed, as long as the day matches the schedule
    if ($never_run) {
        my $when_empty  = !@$when;
        my $day_matches = $self->_day_matches( $reference_day, $when );
        $logger->debug( "never_run check: when_empty=$when_empty, day_matches=$day_matches, reference_day=" . $reference_day->wday );
        if ( $when_empty || $day_matches ) {
            $logger->debug("Job has never run and today matches schedule - allowing immediate run");

            # Return current time (now) to indicate immediate eligibility
            return $now;
        }
    }

    if ( $reference_run_time > $now ) {
        $logger->debug( "NEXT RUN TIME vs NOW: " . $reference_run_time->datetime . " > NOW: " . $now->datetime );

        # Check if reference day is allowed
        if ( !@$when || $self->_day_matches( $reference_day, $when ) ) {
            $logger->debug("no when or day match");
            return $reference_run_time;
        }
    }
    $logger->debug("reference run time was <= now, checking future days");

    # Look for next valid day
    for my $days_ahead ( 1 .. 7 ) {
        my $future_day      = $reference_day + ( $days_ahead * Time::Seconds::ONE_DAY );
        my $future_run_time = $future_day->truncate( to => 'day' ) + ( $hour * 3600 ) + ( $minute * 60 );

        if ( !@$when || $self->_day_matches( $future_day, $when ) ) {
            return $future_run_time;
        }
    }

    return;
}

sub _calculate_weekly_next_run ( $self, $now, $when, $hour, $minute, $never_run = 0 ) {

    # For weekly schedules, 'when' specifies weekdays (1=Monday, 7=Sunday)
    # $now may be adjusted for 24-hour cooldown, so use it as the reference point
    return unless @$when;

    my $reference_day = $now;

    # Special handling for jobs that have never run: allow them to run on the current day
    # even if the scheduled time has passed, as long as the day matches the schedule
    if ( $never_run && $self->_day_matches( $reference_day, $when ) ) {
        $logger->debug("Job has never run and today matches schedule - allowing immediate run");
        return $now;
    }

    # Check remaining days this week and next week
    for my $days_ahead ( 0 .. 13 ) {
        my $check_day      = $reference_day + ( $days_ahead * Time::Seconds::ONE_DAY );
        my $check_run_time = $check_day->truncate( to => 'day' ) + ( $hour * 3600 ) + ( $minute * 60 );

        # Skip if this time has already passed (including cooldown consideration)
        next if $check_run_time <= $now;

        if ( $self->_day_matches( $check_day, $when ) ) {
            return $check_run_time;
        }
    }

    return;
}

sub _calculate_monthly_next_run ( $self, $now, $when, $hour, $minute, $never_run = 0 ) {

    # For monthly schedules, 'when' specifies days of month (1-31)
    # $now may be adjusted for 24-hour cooldown, so use it as the reference point
    return unless @$when;

    my $reference_day = $now;

    # Normalize ordinal strings (1st, 2nd, 3rd, 14th, 21st, etc.) to numbers
    my @normalized_days = map { $self->_ordinal_to_number($_) } @$when;
    @normalized_days = grep { defined $_ } @normalized_days;    # Remove any undefined values

    # Special handling for jobs that have never run: check if today is a scheduled day
    # If so, allow immediate run even if the scheduled time has passed
    my $current_mday = $reference_day->mday;
    if ( $never_run && grep { $_ == $current_mday } @normalized_days ) {
        $logger->debug("Job has never run and today matches schedule - allowing immediate run");
        return $now;
    }

    # Check remaining days this month
    my $current_month = $reference_day->mon;
    my $current_year  = $reference_day->year;

    # Get last day of current month
    my $last_day_of_month = $self->_last_day_of_month( $current_year, $current_month );

    for my $day_of_month ( sort { $a <=> $b } @normalized_days ) {
        next if $day_of_month < 1 || $day_of_month > $last_day_of_month;
        next if $day_of_month < $current_mday;                             # Skip past days this month

        my $check_run_time = Time::Piece->strptime(
            sprintf( "%04d-%02d-%02d %02d:%02d:00", $current_year, $current_month, $day_of_month, $hour, $minute ),
            "%Y-%m-%d %H:%M:%S"
        );

        # Skip if this time has already passed (including cooldown consideration)
        next if $check_run_time <= $now;

        return $check_run_time;
    }

    # No valid days this month, check next month
    my $next_month = $current_month + 1;
    my $next_year  = $current_year;

    if ( $next_month > 12 ) {
        $next_month = 1;
        $next_year++;
    }

    my $last_day_of_next_month = $self->_last_day_of_month( $next_year, $next_month );

    for my $day_of_month ( sort { $a <=> $b } @normalized_days ) {
        next if $day_of_month < 1 || $day_of_month > $last_day_of_next_month;

        my $check_run_time = Time::Piece->strptime(
            sprintf( "%04d-%02d-%02d %02d:%02d:00", $next_year, $next_month, $day_of_month, $hour, $minute ),
            "%Y-%m-%d %H:%M:%S"
        );

        return $check_run_time;
    }

    return;
}

sub _day_matches ( $self, $day, $allowed_days ) {

    # Convert Time::Piece day of week to our format
    # Time::Piece->day_of_week: 0=Sunday, 1=Monday, 2=Tuesday, 3=Wednesday, 4=Thursday, 5=Friday, 6=Saturday
    # We need to convert to: 1=Monday, 2=Tuesday, ..., 7=Sunday (ISO 8601 format)
    # This matches our internal format and the day_name_to_number function

    my $day_of_week = $day->day_of_week;    # 0=Sunday, 1=Monday, ..., 6=Saturday

    # Convert Sunday from 0 to 7 to match ISO 8601 / our internal format
    $day_of_week = 7 if $day_of_week == 0;

    my $day_name = $day->fullday;           # Get full day name for debugging
    $logger->debug( "_day_matches: Using day_of_week=$day_of_week ($day_name), allowed_days=" . join( ',', @$allowed_days ) );

    # Convert allowed_days to numeric format if they contain day names
    my @numeric_allowed_days;
    for my $allowed_day (@$allowed_days) {
        if ( $allowed_day =~ /^\d+$/ ) {

            # Already numeric (should be 1=Monday through 7=Sunday)
            push @numeric_allowed_days, $allowed_day;
        }
        else {
            # Convert day name to number (1=Monday, 7=Sunday)
            my $numeric_day = $self->_day_name_to_number($allowed_day);
            push @numeric_allowed_days, $numeric_day if defined $numeric_day;
        }
    }

    $logger->debug( "_day_matches: numeric_allowed_days=" . join( ',', @numeric_allowed_days ) );

    my $matches = grep { $_ == $day_of_week } @numeric_allowed_days;
    $logger->debug("_day_matches: matches=$matches (checking if $day_of_week is in allowed list)");

    return $matches;
}

sub _day_name_to_number ( $self, $day_name ) {

    # Convert English day names to numeric values (1=Monday, 7=Sunday)
    my %day_map = (
        'monday'    => 1,
        'tuesday'   => 2,
        'wednesday' => 3,
        'thursday'  => 4,
        'friday'    => 5,
        'saturday'  => 6,
        'sunday'    => 7,
    );

    my $normalized_name = lc($day_name);
    return $day_map{$normalized_name};
}

sub _ordinal_to_number ( $self, $value ) {

    # Convert ordinal strings (1st, 2nd, 3rd, 14th, 21st, 28th, etc.) to numbers
    # Return as-is if already numeric
    return $value if $value =~ /^\d+$/;

    # Strip ordinal suffix (st, nd, rd, th) and return the numeric part
    if ( $value =~ /^(\d+)(?:st|nd|rd|th)$/i ) {
        return $1;
    }

    # If not recognized as ordinal, try to return as number or undef
    return $value =~ /^\d+$/ ? $value : undef;
}

sub _last_day_of_month ( $self, $year, $month ) {

    # Create first day of next month, then subtract one day
    my $next_month = $month + 1;
    my $next_year  = $year;

    if ( $next_month > 12 ) {
        $next_month = 1;
        $next_year++;
    }

    my $first_of_next_month = Time::Piece->strptime(
        sprintf( "%04d-%02d-01", $next_year, $next_month ),
        "%Y-%m-%d"
    );

    my $last_of_current_month = $first_of_next_month - Time::Seconds::ONE_DAY;
    return $last_of_current_month->mday;
}

sub _format_time_difference ( $self, $diff_seconds ) {
    return "now" if $diff_seconds <= 0;

    $logger->info("_format_time_difference: $diff_seconds");

    my $hours = int( $diff_seconds / 3600 );
    my $days  = int( $hours / 24 );

    $logger->info("Difference in hours: $hours, days: $days");
    if ( $days >= 1 ) {
        return $days == 1 ? "1 day" : "$days days";
    }
    else {
        return $hours == 1 ? "1 hour" : "$hours hours";
    }
}

1;

__END__

=head1 SCHEDULE FORMAT

The schedule_info hash supports the following structure:

=head2 Daily Schedule

    {
        frequency => 'daily',
        when      => [1, 2, 3, 4, 5],  # Optional: weekdays (1=Mon, 7=Sun)
        time      => '02:00'           # Optional: time in HH:MM format
    }

If 'when' is not specified or empty, the backup runs every day.
If 'when' is specified, it runs only on the specified weekdays.

=head2 Weekly Schedule

    {
        frequency => 'weekly',
        when      => [1, 3, 5],        # Required: weekdays (1=Mon, 7=Sun)
        time      => '03:00'           # Optional: time in HH:MM format
    }

    # Or using day names:
    {
        frequency => 'weekly',
        when      => ['monday', 'wednesday', 'friday'],  # Day names
        time      => '03:00'
    }

Runs on the specified weekdays every week. Days can be specified as numbers 
(1=Monday, 7=Sunday) or as English day names (full names or 3-letter abbreviations).

=head2 Monthly Schedule

    {
        frequency => 'monthly',
        when      => [1, 15, 30],      # Required: days of month (1-31)
        time      => '01:00'           # Optional: time in HH:MM format
    }

Runs on the specified days of each month. If a day doesn't exist in a particular
month (e.g., day 31 in February), it will be skipped for that month.

=head1 EXAMPLES

=head2 Basic Usage

    use Whostmgr::CometBackup::Scheduling ();
    
    my $scheduler = Whostmgr::CometBackup::Scheduling->new();
    
    # Daily backup at 2 AM on weekdays (no cooldown)
    my $daily_schedule = {
        frequency => 'daily',
        when      => [1, 2, 3, 4, 5],  # Monday through Friday
        time      => '02:00'
    };
    
    my $next_run = $scheduler->calculate_next_run($daily_schedule);
    print "Next daily backup: $next_run\n";
    
    # With 24-hour cooldown enforcement
    my $last_run = time() - 3600;  # Last run was 1 hour ago
    $next_run = $scheduler->calculate_next_run($daily_schedule, $last_run);
    print "Next run (with cooldown): $next_run\n";  # Will be 23 hours from now
    
    # Weekly backup on Sundays at 3 AM (using numeric day)
    my $weekly_schedule = {
        frequency => 'weekly',
        when      => [7],              # Sunday
        time      => '03:00'
    };
    
    $next_run = $scheduler->calculate_next_run($weekly_schedule);
    print "Next weekly backup: $next_run\n";
    
    # Weekly backup using day names
    my $weekly_schedule_names = {
        frequency => 'weekly',
        when      => ['monday', 'wednesday', 'friday'],  # Day names
        time      => '18:00'
    };
    
    $next_run = $scheduler->calculate_next_run($weekly_schedule_names);
    print "Next MWF backup: $next_run\n";
    
    # Monthly backup on 1st and 15th at 1 AM
    my $monthly_schedule = {
        frequency => 'monthly',
        when      => [1, 15],          # 1st and 15th of month
        time      => '01:00'
    };
    
    $next_run = $scheduler->calculate_next_run($monthly_schedule);
    print "Next monthly backup: $next_run\n";

=head2 Integration with BackupJobs

    use Whostmgr::CometBackup::BackupJobs ();
    use Whostmgr::CometBackup::Scheduling ();
    
    my $jobs = Whostmgr::CometBackup::BackupJobs->new();
    my $scheduler = Whostmgr::CometBackup::Scheduling->new();
    
    # Get all backup jobs
    my @backup_jobs = $jobs->get_backup_jobs();
    
    for my $job (@backup_jobs) {
        my $time_until_next = $scheduler->next_run_at($job);
        if ($time_until_next) {
            printf "Job '%s' runs in: %s\n", $job->{name}, $time_until_next;
        } else {
            printf "Job '%s' is manual or inactive\n", $job->{name};
        }
    }

=head1 TIME CALCULATIONS

The module uses the server's local time for all calculations. It does not handle
timezone conversions - all times are assumed to be in the server's timezone.

Time differences are formatted as:
- "X hours" for periods less than 24 hours
- "X days" for periods 24 hours or longer

=head2 24-Hour Cooldown Period

When C<last_run_timestamp> is provided to C<calculate_next_run()>, the module
enforces a 24-hour minimum interval between backup runs. This prevents backups
from running too frequently even if the schedule would otherwise allow it.

For example, if a daily backup is scheduled for 2:00 AM every day, but the last
backup ran at 1:00 AM today, the next run will be calculated as 24 hours from
1:00 AM (i.e., 1:00 AM tomorrow) rather than 2:00 AM today.

This cooldown period is consistent with the backup runner's schedule validation
logic and ensures reliable backup intervals.

The module handles edge cases such as:
- Month boundaries and varying month lengths
- Leap years
- Daylight saving time transitions (handled by Time::Piece)
- Invalid dates in monthly schedules (e.g., February 30th)
- 24-hour cooldown enforcement across schedule boundaries

=head1 SEE ALSO

L<Whostmgr::CometBackup::BackupJobs>, L<Time::Piece>, L<Time::Seconds>

=cut
