<?php

require_once('../common/vendor/autoload.php');

use Phpfastcache\CacheManager;
use Phpfastcache\Drivers\Redis\Config;

abstract class Api
{
	public $apiName = ''; //trips
	
	protected $method = ''; //GET|POST|PUT|DELETE
	
	public $requestUri = [];
	public $requestParams = [];
	public $requestHeaders = [];
	public $requestWhitelist = [];
	public $clientIP = null;
	
	protected $action = '';
	//Method name to execute
	protected $encryption = true;

  public $cacheWhitelist = [];
  public $cache;
  public $cacheEnabled = false;


  public function __construct($requestUri, $encryption=true) {
    global $savvyext;
		header("Access-Control-Allow-Orgin: *");
		header("Access-Control-Allow-Methods: *");
		header("Content-Type: application/json");
		
		//GET parameter array separated by slash
		//$this->requestUri = explode('/', trim($_SERVER['REQUEST_URI'],'/'));
        $this->requestUri = $requestUri;

		if (is_array($requestUri) && count($requestUri)>1) {
			if (($pos=strpos($requestUri[1],'?'))!==false) {
				$requestUri[1] = substr($requestUri[1],1+$pos);
			}
			parse_str($requestUri[1],$this->requestParams);
		}

		$this->encryption = $encryption;
		
		//Define the request method
		$this->method = $_SERVER['REQUEST_METHOD'];
		if ($this->method == 'POST' && array_key_exists('HTTP_X_HTTP_METHOD', $_SERVER)) {
			if ($_SERVER['HTTP_X_HTTP_METHOD'] == 'DELETE') {
				$this->method = 'DELETE';
			}
			else if ($_SERVER['HTTP_X_HTTP_METHOD'] == 'PUT') {
				$this->method = 'PUT';
			}
			else {
				throw new Exception("Unexpected Header");
			}
		}

    $this->cacheEnabled = ($savvyext->cfgReadLong('cache.enabled') != NULL && $savvyext->cfgReadLong('cache.enabled') == 1);
    if ($this->cacheEnabled ) {
      $hostsString = $savvyext->cfgReadChar( 'cache.servers' );
      $serverConf = [];
      if ( ! empty( $hostsString ) ) {
        $hostsInfo = explode( ",", $hostsString );
        foreach ( $hostsInfo as $hostInfo ) {
          $hostInfoItems = explode( ":", $hostInfo );
          if ( count( $hostInfoItems ) > 0 ) {
            $serverConf = [
              'host'         => $hostInfoItems[0],
              'port'         => $hostInfoItems[1] ? intval( $hostInfoItems[1] ) : 6379,
            ];
            break;
          }
        }
      }
      if ( count( $serverConf ) != 0 ) {
        $conf = new Config($serverConf);
        $this->cache = CacheManager::getInstance( 'redis', $conf );
      } else {
        $this->cacheEnabled = false;
      }
    }

		if ($this->method=="POST") {
			$raw_json = file_get_contents("php://input");
			$this->requestParams = json_decode($raw_json, true);
		}
		
		if ($this->method == "PUT") {
            // Do we do key=val&key=val ?
			$raw_json = file_get_contents("php://input");
			if (strpos($raw_json, 'encrypted_payload') !== false) {
				$this->requestParams = json_decode($raw_json, true);
			} else {
				parse_str($raw_json, $this->requestParams);
			}
		}

		// We can inspect the headers later on
		$this->loadRequestHeaders();

		// Decrypt the input
		if (isset($this->requestParams['encrypted_payload'])) {
            $encryptionAlg = $savvyext->cfgReadChar('encryption.algorithm');
			$encryptionKey = $savvyext->cfgReadChar('encryption.key');
			$encryptionIV = $savvyext->cfgReadChar('encryption.iv');
			$payload = openssl_decrypt(
                hex2bin(
                    $this->requestParams['encrypted_payload']
                ), 
                $encryptionAlg, 
                $encryptionKey, 
                OPENSSL_RAW_DATA, 
                $encryptionIV
            );
			unset($this->requestParams['encrypted_payload']);
			if (is_array($this->requestParams) && count($this->requestParams)>0) {
				$this->requestParams = array_merge($this->requestParams, json_decode($payload, true));
			} else {
				$data = json_decode($payload, true);
				$this->requestParams = is_array($data) ? $data : [];
			}
		}
	}

	// No action taken YET!
	// TODO: delegate the decision into controller, the default (unset) behaviour is to block
	protected function checkRequestHeaders($db, $action) {
		error_log('Checking '.$this->apiName.'::'.$action.'...');
		if (array_key_exists($action,$this->requestWhitelist)) {
			error_log('whitelisted!');
			return true;
		}
		$sessionID = null;
		$deviceToken = null;
		if (array_key_exists("x-session-id",$this->requestHeaders)) {
			$sessionID = $this->requestHeaders["x-session-id"];
		}
		if (array_key_exists("x-devicetoken",$this->requestHeaders)) {
			$deviceToken = $this->requestHeaders["x-devicetoken"];
		}
		error_log('X-Session-ID: '.$sessionID);
		error_log('X-DeviceToken: '.$deviceToken);
		// Step 1a: Get member_id by X-DeviceToken
		$header_member_id = 0;
		$conn = $db->getConnect();
		$q = "SELECT * FROM members_devices WHERE access_token='".pg_escape_string($deviceToken)."'";
		$r = pg_query($conn, $q);
		if ($r && pg_num_rows($r) && $f=pg_fetch_assoc($r)) {
			$header_member_id = $f['member_id'];
			$q = "UPDATE members_devices SET updated=now(), status=1 WHERE id=".((int)$f['id'])." RETURNING *";
			$r = pg_query($conn, $q);
			if ($r && pg_num_rows($r) && $f=pg_fetch_assoc($r)) {
				error_log('Status updated at: '.$f['updated']);
			}
		}
		if ($header_member_id<1) {
			//return false; //throw new RuntimeException('Invalid header member ID', 500);
		}
		// Step 1b: Get member_id by X-Session-ID
		$session_member_id = 0;
		$q = "SELECT * FROM members_session WHERE session='".pg_escape_string($sessionID)."'";
		$r = pg_query($conn, $q);
		if ($r && pg_num_rows($r) && $f=pg_fetch_assoc($r)) {
			$session_member_id = $f['member_id'];
			if ($header_member_id<1) {
				$q = "UPDATE members_devices SET updated=now(), status=1 WHERE id=".((int)$f['id'])." RETURNING *";
				$r = pg_query($conn, $q);
				if ($r && pg_num_rows($r) && $f=pg_fetch_assoc($r)) {
					error_log('Status updated at: '.$f['updated']);
				}
			}
		}
		if ($session_member_id<1) {
			//return false; //throw new RuntimeException('Invalid session member ID', 500);
		}
		// Step 2: Get member_id from $this->requestParams
		$request_member_id = 0;
		if (array_key_exists('member_id',$this->requestParams)) {
			$request_member_id = (int)$this->requestParams['member_id'];
		}
		error_log('member_id[request] = '.$request_member_id);
		error_log('member_id[token]   = '.$header_member_id);
		error_log('member_id[session] = '.$session_member_id);
		if ($request_member_id != $session_member_id && $session_member_id>0) {
			//$request_member_id = $session_member_id;
			$this->requestParams['member_id'] = $session_member_id;
		}
		// Step 3a: Match Step 1 and 2 result
		if ($request_member_id>0) {
			// Step 3b: Fallback to X-Session-ID?
			if ($request_member_id!=$header_member_id || $request_member_id!=$session_member_id) {
				//return false; //throw new RuntimeException('Invalid request member ID', 500);
			}
		}
		return true;
	}

	protected function loadRequestHeaders() {
		$this->requestHeaders = [];
		foreach (getallheaders() as $key=>$val) {
			// https://cloud.google.com/load-balancing/docs/https/
			// After September 30, HTTP(S) Load Balancers will convert HTTP/1.1 header names to lowercase 
			// in the request and response directions; header values will not be affected.
			//error_log('DEBUG: "'.$key.'" => "'.$val.'"');
			$this->requestHeaders[strtolower($key)] = $val;
		}
		return count($this->requestHeaders);
	}
	
	protected function checkThrottling($db) {
		if (!empty($_SERVER['HTTP_CLIENT_IP']) && filter_var($_SERVER['HTTP_CLIENT_IP'], FILTER_VALIDATE_IP)) {
			$ip = pg_escape_string($_SERVER['HTTP_CLIENT_IP']);
		} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR']) && filter_var($_SERVER['HTTP_X_FORWARDED_FOR'], FILTER_VALIDATE_IP)) {
			$ip = pg_escape_string($_SERVER['HTTP_X_FORWARDED_FOR']);
		} else {
			// Will not make much sense since we are behind the reverse proxy
			$ip = pg_escape_string($_SERVER['REMOTE_ADDR']);
		}
		$this->clientIP = $ip;
		$lastRec = NULL;
		$rec = NULL;
		$conn = $db->getConnect();
		$q = "SELECT *,(EXTRACT(EPOCH FROM time_last) * 1000)::bigint AS last_ms FROM throttling_ip WHERE ip='${ip}'";
		$r = pg_query($conn, $q);
		if ($r && pg_num_rows($r) && $f=pg_fetch_assoc($r)) {
			$lastRec = $f;
			$q = "UPDATE throttling_ip SET total=total+1,time_last=NOW() WHERE ip='${ip}' RETURNING *,(EXTRACT(EPOCH FROM time_last) * 1000)::bigint AS last_ms";
			$r = pg_query($conn, $q);
			if ($r && pg_num_rows($r) && $f=pg_fetch_assoc($r)) {
				$rec = $f;
			} 
		} else {
			$q = "INSERT INTO throttling_ip (ip) VALUES ('${ip}') RETURNING *,(EXTRACT(EPOCH FROM time_last) * 1000)::bigint AS last_ms";
			$r = pg_query($conn, $q);
			if ($r && pg_num_rows($r) && $f=pg_fetch_assoc($r)) {
				$rec = $f;
			}
		}
		if (!$lastRec && $rec && is_array($rec) && count($rec)>0) {
			error_log('New IP spotted!');
			return true; // New record - never seen this IP before...
		}
		if (!$lastRec && (!$rec || !is_array($rec) || !count($rec)<1)) {
			// Failed to insert new record
			error_log('Error: not throttling!');
			return true; // TODO: should we fail?
		}
		// Compare $lastRec to $rec
		if (($rec["last_ms"]-$lastRec["last_ms"]) > 10) {
			error_log('Not throttle!');
			return true; 
		} else {
			error_log('Throttle: ' . $rec["last_ms"] . " - " . $lastRec["last_ms"] . " = ".($rec["last_ms"]-$lastRec["last_ms"]));
			return true; // Throttle if less than a second within
		}
		return true; // OK
	}

  protected  function isActionCached($action) {
    error_log('Check is it '.$this->apiName.'::'.$action.' could be cached...');
    if (array_key_exists($action,$this->cacheWhitelist) || in_array($action,$this->cacheWhitelist)) {
      $ttl = $this->cacheWhitelist[$action]['ttl'] ?? 60;
      if ($ttl == 0) {
        return false;
      }
      error_log('whitelisted!');
      return true;
    }
    else return false;
  }

	public function run() {

		//The first 2 elemets of URI array must by "api" and table name
		if(array_shift($this->requestUri) !== $this->apiName){
			throw new RuntimeException('API Not Found', 404);
		}
		//Select the action to execute
		$this->action = $this->getAction();

		$db = new Db();
		// Inspect throttling
		if (!$this->checkThrottling($db)) {
			unset($db);
			throw new RuntimeException('Too Many Requests', 429);
		}

		// Inspect header
		if (!$this->checkRequestHeaders($db, $this->action)) {
			unset($db);
			throw new RuntimeException('Request check failed', 500);
		}

		unset($db);
		
		//If the method (action) defined in the child API class
		if (method_exists($this, $this->action)) {

      $result = null;

      if( $this->cacheEnabled && $this->isActionCached($this->action)) {
        $key = $this->getCacheKey();
        $ttl = $this->cacheWhitelist[$this->action]['ttl'] ?? 300;
        $cachedString = $this->cache->getItem($key);
        $result = $cachedString->get();
        if (!is_null($result)) {
          return $result;
        }
        $result = $this->{
        $this->action
        }
        ();
        if($this->storeInCache()) {
          $cachedString->set($result)->expiresAfter($ttl);
          $this->cache->save($cachedString);
        }
        return $result;
      } else {
        return $this->{
        $this->action
        }
        ();
      }
		}
		else {
			throw new RuntimeException('Invalid Method', 405);
		}
	}
	
	protected function response($data, $status = 500) {
        global $savvyext;
		header("HTTP/1.1 " . $status . " " . $this->requestStatus($status));
		if ($this->encryption) {
			// encrypt data
			$encryptionAlg = $savvyext->cfgReadChar('encryption.algorithm');
			$encryptionKey = $savvyext->cfgReadChar('encryption.key');
			$encryptionIV = $savvyext->cfgReadChar('encryption.iv');
			$payload = json_encode($data);
			$encrypted_payload = bin2hex(
			                openssl_encrypt(
			                    $payload, 
			                    $encryptionAlg, 
			                    $encryptionKey, 
			                    OPENSSL_RAW_DATA, 
			                    $encryptionIV
			                )
                        );
            $data = array(); // Comment out to see the data
			$data["payload"] = $encrypted_payload;
        }
		return json_encode($data);
	}
	
	private function requestStatus($code) {
		$status = array(
		            200 => 'OK',
		            404 => 'Not Found',
		            405 => 'Method Not Allowed',
		            500 => 'Internal Server Error',
		        );
		return ($status[$code])?$status[$code]:$status[500];
	}
	
	protected function getAction()
	    {
		$method = $this->method;
		switch ($method) {
            case 'GET':
                $pos = strpos($this->requestUri[0],'?');
                if ($pos!==false && $pos==0) {
                    // We want to get "all"
                    $this->requestUri[0] = "all".$this->requestUri[0];
                }
                $tok = strtok($this->requestUri[0],'?');
			    if($tok===false || $tok=='all'){
				    return 'indexAction';
			    } else {
				    return 'viewAction';
			    }
			break;
			case 'POST':
			    return 'createAction';
			break;
			case 'PUT':
			    return 'updateAction';
			break;
			case 'DELETE':
			    return 'deleteAction';
			break;
			default:
			    return null;
		}
	}
	
	abstract protected function indexAction();
	abstract protected function viewAction();
	abstract protected function createAction();
	abstract protected function updateAction();
	abstract protected function deleteAction();

	protected  function getCacheKey() {
	  return hash('md5', $this->method.'|'.$this->apiName.'|'.$this->action.'|'.json_encode($this->requestParams));
  }

  protected  function storeInCache() {
    return http_response_code() === 200;
	}


}

