<?php

namespace Groundhogg;

use Groundhogg\Background\Schedule_Broadcast;
use Groundhogg\Classes\Background_Task;
use Groundhogg\DB\Broadcast_Meta;
use Groundhogg\DB\Broadcasts;
use Groundhogg\DB\Query\Table_Query;
use Groundhogg\Reporting\New_Reports\Traits\Broadcast_Stats;
use Groundhogg\Utils\DateTimeHelper;
use Groundhogg\Utils\Micro_Time_Tracker;
use GroundhoggSMS\Classes\SMS;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Broadcast
 *
 * This is a simple class that inits a broadcast like object for easy use and manipulation.
 * Also contains some api methods for the event queue
 *
 * @since       File available since Release 0.1
 * @author      Adrian Tobey <info@groundhogg.io>
 * @copyright   Copyright (c) 2018, Groundhogg Inc.
 * @license     https://opensource.org/licenses/GPL-3.0 GNU Public License v3
 * @package     Includes
 */
class Broadcast extends Base_Object_With_Meta implements Event_Process {

	const TYPE_SMS = 'sms';
	const TYPE_EMAIL = 'email';
	const FUNNEL_ID = 1;

	/**
	 * @var SMS|Email
	 */
	protected $object;

	/**
	 * Flag for whether the hook was added to check if the broadcast has been fully sent or not.
	 *
	 * @var bool
	 */
	static $sent_hook_set = false;

	/**
	 * Whether the object is transactional and thus avoids marketability.
	 *
	 * @return bool
	 */
	public function is_transactional() {

		$object = $this->get_object();
		if ( method_exists( $object, 'is_transactional' ) ) {
			return $object->is_transactional();
		}

		return false;
	}

	/**
	 * If the broadcast has been fully scheduled.
	 * Not the same as if the status is scheduled, which is mostly for display in the admin
	 *
	 * @return bool
	 */
	public function is_scheduled() {
		return boolval( $this->get_meta( 'is_scheduled' ) );
	}

	public function is_pending() {
		return $this->get_status() === 'pending';
	}

	public function is_cancelled() {
		return $this->get_status() === 'cancelled';
	}

	public function is_sent() {
		return $this->status_is( 'sent' );
	}

	public function status_is( $status ) {
		return $this->get_status() === $status;
	}

	/**
	 * If the broadcast is in the process of sending
	 *
	 * @return bool
	 */
	public function is_sending() {
		return $this->status_is( 'sending' );
	}

	/**
	 * If the broadcast is sent
	 * or if there are no pending events remaining
	 *
	 * @return bool
	 */
	public function is_fully_sent() {
		return $this->is_sent() || ! $this->has_pending_events();
	}

	/**
	 * Do any post setup actions.
	 *
	 * @return void
	 */
	protected function post_setup() {

		$this->query = maybe_unserialize( $this->query );

		switch ( $this->get_broadcast_type() ) {
			case self::TYPE_EMAIL:
				$this->object = new Email( $this->get_object_id() );
				break;
			case self::TYPE_SMS:

				if ( is_sms_plugin_active() ) {
					$this->object = new SMS( $this->get_object_id() );
				}

				break;
		}
	}

	/**
	 * Return the DB instance that is associated with items of this type.
	 *
	 * @return Broadcasts
	 */
	protected function get_db() {
		return get_db( 'broadcasts' );
	}

	/**
	 * Returns meta db for the Broadcast
	 *
	 * @return Broadcast_Meta
	 */
	protected function get_meta_db() {
		return get_db( 'broadcastmeta' );
	}

	/**
	 * @return int
	 */
	public function get_id() {
		return absint( $this->ID );
	}

	/**
	 * A string to represent the object type
	 *
	 * @return string
	 */
	protected function get_object_type() {
		return 'broadcast';
	}

	/**
	 * @return string|void
	 */
	public function get_funnel_title() {
		if ( $this->is_email() ) {
			return esc_html__( 'Broadcast Email', 'groundhogg' );
		} else {
			return esc_html__( 'Broadcast SMS', 'groundhogg' );
		}
	}

	/**
	 * @return string
	 */
	public function get_step_title() {
		return $this->get_title();
	}

	/**
	 * The query object
	 *
	 * @return array
	 */
	public function get_query() {
		return $this->query;
	}

	/**
	 * @return string
	 */
	public function get_broadcast_type() {
		return $this->object_type;
	}

	/**
	 * @return int
	 */
	public function get_object_id() {
		return absint( $this->object_id );
	}

	/**
	 * Whether the broadcast is sending an sms
	 *
	 * @return bool
	 */
	public function is_sms() {
		return $this->get_broadcast_type() === self::TYPE_SMS;
	}

	/**
	 * Whether the broadcast is sending an email
	 *
	 * @return bool
	 */
	public function is_email() {
		return $this->get_broadcast_type() === self::TYPE_EMAIL;
	}

	/**
	 * @return Email|SMS|null
	 */
	public function get_object() {
		return $this->object;
	}

	public function get_send_time() {
		return absint( $this->send_time );
	}

	public function get_scheduled_by_id() {
		return absint( $this->scheduled_by );
	}

	public function get_funnel_id() {
		return self::FUNNEL_ID;
	}

	public function get_status() {
		return $this->status;
	}

	public function get_date_scheduled() {
		return $this->date_scheduled;
	}

	/**
	 * Get the column row title for the broadcast.
	 *
	 * @return string
	 */
	public function get_title() {

		if ( ! $this->get_object() || ! $this->get_object()->exists() ) {
			return esc_html__( 'The associated object was deleted.', 'groundhogg' );
		}

		return esc_html( $this->get_object()->get_title() );
	}

	/**
	 * Calls the background task to schedule the broadcast
	 *
	 * @return bool|\WP_Error
	 */
	public function maybe_schedule_in_background() {

		if ( ! $this->is_schedulable() ) {
			return false;
		}

		$added = Background_Tasks::add(
			new Schedule_Broadcast( $this->get_id() ),
			$this->get_meta( 'segment_type' ) === 'dynamic'
				? max( $this->get_send_time() - ( 10 * MINUTE_IN_SECONDS ), time() ) // start 10 minutes before actual send time
				: time()
		);

		if ( is_wp_error( $added ) ) {
			return $added;
		}

		$task_id = Background_Tasks::get_last_added_task_id();
		$this->update_meta( 'task_id', $task_id );

		return true;
	}

	/**
	 * If there is at least 1 pending event
	 *
	 * @return bool
	 */
	public function has_pending_events() {

		$query = new Table_Query( 'event_queue' );
		$query->setLimit( 1 )->where()->equals( 'step_id', $this->ID )
		      ->equals( 'funnel_id', self::FUNNEL_ID )
		      ->equals( 'event_type', Event::BROADCAST )
		      ->equals( 'status', Event::WAITING );

		return count( $query->get_results() ) > 0;
	}


	public function count_pending_events() {

		$query = new Table_Query( 'event_queue' );
		$query->setLimit( 1 )->where()->equals( 'step_id', $this->ID )
		      ->equals( 'funnel_id', self::FUNNEL_ID )
		      ->equals( 'event_type', Event::BROADCAST )
		      ->equals( 'status', Event::WAITING );

		return $query->count();
	}

	/**
	 * Cancel the broadcast
	 *
	 * @noinspection PhpPossiblePolymorphicInvocationInspection
	 */
	public function cancel() {

		// already cancelled
		if ( $this->is_cancelled() ) {
			return true;
		}

		// if there are no pending events for this broadcast that means it's already fully sent
		if ( $this->is_sent() ) {
			return false;
		}

		// Also cancel the associated background task
		if ( $task_id = $this->get_meta( 'task_id' ) ) {
			$task = new Background_Task( $task_id );
			if ( ! $task->is_done() ) {
				$task->cancel();
			}
		}

		// Cancel events in the event queue
		get_db( 'event_queue' )->mass_update(
			[
				'status' => Event::CANCELLED
			],
			[
				'step_id'    => $this->get_id(),
				'funnel_id'  => Broadcast::FUNNEL_ID,
				'event_type' => Event::BROADCAST
			]
		);

		// Move them to the history table
		get_db( 'event_queue' )->move_events_to_history( [
			'status' => Event::CANCELLED,
		] );

		// Set status to cancelled finally
		$this->update( [ 'status' => 'cancelled' ] );

		return true;
	}

	/**
	 * If the broadcast can be scheduled
	 *
	 * @return bool
	 */
	public function is_schedulable() {
		return ( $this->status_is( 'pending' )
		         || $this->status_is( 'sending' ) ) && ! $this->is_scheduled();
	}

	/**
	 * Whether to use the legacy schedule function, which will be the case if there is no last_id but events were previously scheduled
	 *
	 * @return bool
	 */
	public function use_legacy_schedule() {
		$num_scheduled = absint( $this->get_meta( 'num_scheduled' ) ) ?: 0;
		$last_id       = absint( $this->get_meta( 'last_id' ) ) ?: 0;

		return $num_scheduled > 0 && ! $last_id;
	}

	/**
	 * Enqueue a batch of broadcast events
	 *
	 * @return bool|int
	 */
	public function enqueue_batch() {

		// might need to use the legacy function for any broadcasts that are being scheduled already
		if ( $this->use_legacy_schedule() ) {
			return $this->enqueue_batch_legacy();
		}

		$lock = absint( $this->get_meta( 'schedule_lock' ) );

		// This broadcast is already being scheduled
		if ( $lock > 1 && time() - $lock < MINUTE_IN_SECONDS ) {
			return 0;
		}

		// This broadcast has already been scheduled
		if ( ! $this->is_schedulable() ) {
			return false;
		}

		// Lock scheduling
		$this->update_meta( 'schedule_lock', time() );

		// timer
		$timer = new Micro_Time_Tracker();

		// keep track of the number of scheduled items in this batch
		$items         = 0;
		$query         = $this->get_query();
		// remove ordering from the query so as to not conflict with required ID ordering.
		unset( $query['order'] );
		unset( $query['orderby'] );
		unset( $query['limit'] );
		unset( $query['number'] );

		$in_lt         = (bool) $this->get_meta( 'send_in_local_time' );
		$send_now      = (bool) $this->get_meta( 'send_now' );
		$num_scheduled = absint( $this->get_meta( 'num_scheduled' ) ) ?: 0;
		$last_id       = absint( $this->get_meta( 'last_id' ) ) ?: 0;

		$c_query = new Contact_Query( $query );
		$c_query->setOrderby( [ 'ID', 'ASC' ] )
		        ->setGroupby( 'ID' )
		        ->setLimit( self::BATCH_LIMIT );

		if ( $last_id > 0 ) {
			$c_query->where()->greaterThan( 'ID', $last_id );
		}

		$contacts = $c_query->query( null, true );

		// no more contacts to schedule
		if ( empty( $contacts ) ) {
			$this->update_meta( 'is_scheduled', true );

			// if the broadcast is still pending, we can update the status to scheduled
			// the scheduled status is now for display only, use the is_scheduled meta flag
			if ( $this->is_pending() ) {
				$this->update( [ 'status' => 'scheduled' ] );
			}

			$this->delete_meta( 'schedule_lock' );

			return true;
		}

		$batch_interval        = $this->get_meta( 'batch_interval' );
		$batch_interval_length = absint( $this->get_meta( 'batch_interval_length' ) );
		$batch_amount          = absint( $this->get_meta( 'batch_amount' ) ) ?: 100;
		$batch_delay           = absint( $this->get_meta( 'batch_delay' ) ) ?: 0;

		foreach ( $contacts as $contact ) {

			$last_id = $contact->ID;
			$num_scheduled ++;
			$items ++;

			// if the number of scheduled items has reached the batch amount threshold
			if ( $batch_interval && $num_scheduled > 0 && $num_scheduled % $batch_amount === 0 ) {
				// increase the batch delay
				$batch_delay = strtotime( "+$batch_interval_length $batch_interval", $batch_delay );
			}

			// Can't be delivered at all
			if ( ! $contact->is_deliverable() ) {
				continue;
			}

			// No point in scheduling an email to a contact that is not marketable.
			if ( ! $this->is_transactional() && ! $contact->is_marketable() ) {
				continue;
			}

			$send_time = $this->get_send_time();

			// Send in the local time, maybe
			if ( $in_lt && ! $send_now ) {

				$local_time = $contact->get_local_time_in_utc_0( $send_time );

				// if not in the same TZ
				if ( $local_time !== $send_time ) {

					if ( $local_time < $send_time ) {
						$local_time += DAY_IN_SECONDS;
					}

					$send_time = $local_time;
				}
			}

			if ( $batch_interval && $batch_delay ) {
				$send_time += $batch_delay;
			}

			$args = [
				'time'       => $send_time,
				'contact_id' => $contact->get_id(),
				'funnel_id'  => Broadcast::FUNNEL_ID,
				'step_id'    => $this->get_id(),
				'event_type' => Event::BROADCAST,
				'status'     => Event::WAITING,
				'priority'   => 100,
			];

			if ( $this->is_email() ) {
				$args['email_id'] = $this->get_object_id();
			}

			event_queue_db()->batch_insert( $args );
		}

		$inserted = event_queue_db()->commit_batch_insert();

		// Failed to add an events, but there are contacts to enqueue
		if ( ! $inserted ) {
			// Reset the lock
			$this->delete_meta( 'schedule_lock' );

			return 0;
		}

		$time_elapsed = $timer->time_elapsed();

		$this->update_meta( 'num_scheduled', $num_scheduled );
		$this->update_meta( 'batch_time_elapsed', number_format( $time_elapsed, 2 ) );
		$this->update_meta( 'batch_delay', $batch_delay );
		$this->update_meta( 'last_id', $last_id );

		// we're going to let the scheduler do an additional query that will return no contacts,
		// and that's how we'll know we're done scheduling.

		$this->delete_meta( 'schedule_lock' );

		return $items;

	}

	/**
	 * Schedules a batch of events!
	 *
	 * @return false|float false if failed, a number of percentage complete
	 */
	public function enqueue_batch_legacy() {

		$lock = absint( $this->get_meta( 'schedule_lock' ) );

		// This broadcast is already being scheduled
		if ( $lock > 1 && time() - $lock < MINUTE_IN_SECONDS ) {
			return 0;
		}

		// This broadcast has already been scheduled
		if ( ! $this->is_schedulable() ) {
			return false;
		}

		$timer = new Micro_Time_Tracker();
		$items = 0;

		// Lock scheduling
		$this->update_meta( 'schedule_lock', time() );

		$query               = $this->get_query();
		$in_lt               = (bool) $this->get_meta( 'send_in_local_time' );
		$send_now            = (bool) $this->get_meta( 'send_now' );
		$offset              = absint( $this->get_meta( 'num_scheduled' ) ) ?: 0;
		$limit               = self::BATCH_LIMIT;
		$query['number']     = $limit;
		$query['offset']     = $offset;
		$query['found_rows'] = true;

		$c_query = new Contact_Query( $query );
		$c_query->setOrderby( [ 'ID', 'ASC' ] )->setGroupby( 'ID' );
		$contacts = $c_query->query( null, true );
		$total    = $c_query->found_items;

		$batch_interval        = $this->get_meta( 'batch_interval' );
		$batch_interval_length = absint( $this->get_meta( 'batch_interval_length' ) );
		$batch_amount          = absint( $this->get_meta( 'batch_amount' ) ) ?: 100;
		$batch_delay           = absint( $this->get_meta( 'batch_delay' ) ) ?: 0;

		// No contacts to send to?
		if ( $total === 0 ) {
			return false;
		}

		foreach ( $contacts as $contact ) {

			$offset ++;
			$items ++;

			// if the number of scheduled items has reached the batch amount threshold
			if ( $batch_interval && $offset > 0 && $offset % $batch_amount === 0 ) {
				// increase the batch delay
				$batch_delay = strtotime( "+$batch_interval_length $batch_interval", $batch_delay );
			}

			// Can't be delivered at all
			if ( ! $contact->is_deliverable() ) {
				continue;
			}

			// No point in scheduling an email to a contact that is not marketable.
			if ( ! $this->is_transactional() && ! $contact->is_marketable() ) {
				continue;
			}

			$local_time = $this->get_send_time();

			// Send in the local time, maybe
			if ( $in_lt && ! $send_now ) {

				$local_time = $contact->get_local_time_in_utc_0( $local_time );

				if ( $local_time < time() ) {
					$local_time += DAY_IN_SECONDS;
				}
			}

			if ( $batch_interval && $batch_delay ) {
				$local_time += $batch_delay;
			}

			$args = [
				'time'       => $local_time,
				'contact_id' => $contact->get_id(),
				'funnel_id'  => Broadcast::FUNNEL_ID,
				'step_id'    => $this->get_id(),
				'event_type' => Event::BROADCAST,
				'status'     => Event::WAITING,
				'priority'   => 100,
			];

			if ( $this->is_email() ) {
				$args['email_id'] = $this->get_object_id();
			}

			event_queue_db()->batch_insert( $args );
		}

		$inserted = event_queue_db()->commit_batch_insert();

		// Failed to add an events, but there are contacts to send to
		if ( $total > 0 && ! $inserted ) {
			// Reset the lock
			$this->delete_meta( 'schedule_lock' );

			return 0;
		}

		$time_elapsed = $timer->time_elapsed();

		$this->update_meta( 'num_scheduled', $offset );
		$this->update_meta( 'total_contacts', $total );
		$this->update_meta( 'batch_time_elapsed', number_format( $time_elapsed, 2 ) );
		$this->update_meta( 'batch_delay', $batch_delay );

		// Finished scheduling
		if ( $offset >= $total ) {
			$this->update_meta( 'is_scheduled', true );

			// if the broadcast is still pending, we can update the status to scheduled
			// the scheduled status is now for display only, use the is_scheduled meta flag
			if ( $this->is_pending() ) {
				$this->update( [ 'status' => 'scheduled' ] );
			}
		}

		$this->delete_meta( 'schedule_lock' );

		return $items;

	}

	/**
	 * Add an action for status changes
	 *
	 * @param $data
	 *
	 * @return bool
	 */
	public function update( $data = [] ) {

		$prevStatus = $this->get_status();

		$result = parent::update( $data );

		$newStatus = $this->get_status();

		if ( $prevStatus !== $newStatus ) {

			/**
			 * When a broadcast's status changes
			 *
			 * @param $broadcast Broadcast
			 */
			do_action( "groundhogg/broadcast/$newStatus", $this );
		}

		return $result;
	}

	/**
	 * Updates the broadcast's status to sent if criteria are met
	 *
	 * @return bool
	 */
	public function maybe_set_status_to_sent() {

		// pull any changes that might have been made by other events
		$this->pull();

		// if the broadcast has not finished scheduling, no go.
		if ( ! $this->is_scheduled() ) {
			return false;
		}

		// We're not done sending events yet
		if ( $this->has_pending_events() ) {
			return false;
		}

		// We have to at least sent a few emails, right?
		if ( ! $this->status_is( 'sending' ) ) {
			return false;
		}

		$this->update( [ 'status' => 'sent' ] );

		return true;
	}

	/**
	 * Transition broadcasts from sending to sent
	 *
	 * @throws DB\Query\FilterException
	 * @return void
	 */
	public static function transition_from_sending_to_sent() {

		$sending = db()->broadcasts->query( [
			'status' => 'sending'
		] );

		if ( empty( $sending ) ) {
			return;
		}

		foreach ( $sending as $broadcast ) {
			$broadcast = new Broadcast( $broadcast );
			$broadcast->maybe_set_status_to_sent();
		}
	}

	/**
	 * Send the associated object to the given contact
	 *
	 * @param $contact Contact
	 * @param $event   Event
	 *
	 * @return bool|\WP_Error whether the email sent or not.
	 */
	public function run( $contact, $event = null ) {

		/**
		 * Fires before the broadcast is sent
		 *
		 * @param Broadcast $broadcast
		 * @param Contact   $contact
		 * @param Event     $event
		 */
		do_action( "groundhogg/broadcast/{$this->get_broadcast_type()}/before", $this, $contact, $event );
		do_action( "groundhogg/broadcast/before", $this, $contact, $event );

		/**
		 * Filter the object to send...
		 *
		 * @param Email|SMS $object
		 * @param Broadcast $broadcast
		 */
		$object = apply_filters( "groundhogg/broadcast/{$this->get_broadcast_type()}/object", $this->get_object(), $this, $contact, $event );

		if ( ! $object || ! $object->exists() ) {
			return new \WP_Error( 'object_error', 'Could not find email or SMS to send.' );
		}

		$result = $object->send( $contact, $event );

		/**
		 * Fires after the broadcast is sent
		 *
		 * @param Broadcast $broadcast
		 * @param Contact   $contact
		 * @param Event     $event
		 */
		do_action( "groundhogg/broadcast/{$this->get_broadcast_type()}/after", $this, $contact, $event );
		do_action( "groundhogg/broadcast/after", $this, $contact, $event );

		// Set the status to sending while we're sending the broadcast
		// Regardless of the previous status
		if ( ! $this->status_is( 'sending' ) ) {

			// directly from pending
			if ( $this->is_pending() ) {
				// set the status to schedule first to run any hooks temporarily
				$this->update( [ 'status' => 'scheduled' ] );
			}

			// immediately then set it to sending
			$this->update( [ 'status' => 'sending' ] );
		}

		if ( ! self::$sent_hook_set ) {
			add_action( 'groundhogg/queue/processed_events', [ __CLASS__, 'transition_from_sending_to_sent' ] );
			self::$sent_hook_set = true;
		}

		return $result;
	}

	/**
	 * Just return true for now cuz I'm lazy...
	 *
	 * @return bool
	 */
	public function can_run() {
		return true;
	}

	protected $report_data = [];

	use Broadcast_Stats;

	/**
	 * @return array
	 */
	public function get_report_data( $unused = 0 ) {

		// let's maybe cache the report date
		if ( ! empty( $this->report_data ) ) {
			return $this->report_data;
		}

		$cached_report_data = $this->get_meta( 'cached_report_data' );
		if ( ! empty( $cached_report_data ) ) {
			$this->report_data = $cached_report_data;

			return $this->report_data;
		}

		$data = [];

		$data['waiting'] = get_db( 'event_queue' )->count( [
			'step_id'    => $this->get_id(),
			'event_type' => Event::BROADCAST,
			'status'     => Event::WAITING,
		] );

		$data['id'] = $this->get_id();

		if ( $this->is_sent() || $this->status_is( 'sending' ) ) {

			$stats_data = $this->get_broadcast_stats();
			$data       = array_merge( $data, $stats_data );
		}

		// lets cached the broadcasts results
		$this->update_meta( 'cached_report_data', $data );

		$this->report_data = $data;

		return $data;
	}

	public function clear_cached_report_data() {
		return $this->delete_meta( 'cached_report_data' );
	}

	/**
	 * @return array
	 */
	public function get_as_array() {

		$date = new DateTimeHelper( $this->get_send_time() );

		return array_merge( parent::get_as_array(), [
			'object'           => $this->get_object(),
			'date_sent_pretty' => $date->wpDateTimeFormat(),
			'title' => $this->get_title(),
		] );
	}

	const BATCH_LIMIT = 500;

	/**
	 * Get the number of items remaining that require scheduling
	 *
	 * @return array|false|mixed
	 */
	public function get_items_remaining() {
		$total = $this->get_meta( 'total_contacts' );

		// fallback for total contacts
		if ( ! $total ) {
			$total = get_db()->contacts->count( $this->get_query() );
			$this->update_meta( 'total_contacts', $total );
		}

		$scheduled = $this->get_meta( 'num_scheduled' );

		if ( ! $total || ! $scheduled ) {
			return false;
		}

		return $total - $scheduled;
	}

	/**
	 * The estimated time to completed scheduling all the events in seconds
	 *
	 * @return false|float time in seconds
	 */
	public function get_estimated_scheduling_time_remaining() {

		$remaining    = $this->get_items_remaining();
		$time_elapsed = $this->get_meta( 'batch_time_elapsed' );

		if ( ! $remaining || ! $time_elapsed ) {
			return false;
		}

		return ceil( ( $remaining / self::BATCH_LIMIT ) * $time_elapsed );
	}

	/**
	 * A percentage value of the
	 *
	 * @return float|int
	 */
	public function get_percent_scheduled() {

		$offset = absint( $this->get_meta( 'num_scheduled' ) );
		$total  = absint( $this->get_meta( 'total_contacts' ) );

		return percentage( $total, $offset );
	}
}
