<?php
/**
 * BitFire PHP based Firewall.
 * Author: BitFire (BitSlip6 company)
 * Distributed under the AGPL license: https://www.gnu.org/licenses/agpl-3.0.en.html
 * Please report issues to: https://github.com/bitslip6/bitfire/issues
 *
 * HTML/JS Recursive Renderer and minifier.
 * TODO: Refactor to use FileData and Effect abstractions.
 */
namespace ThreadFin\view;

use const ThreadFin\DAY;

// polly-fill for gettext
if (!function_exists('_')) { function _(string $in) : string { return $in; } }

/**
 * take a unix timestamp and return localized string for number of days ago
 * @param string $epoch 
 * @return string 
 */
function days_format(string $epoch) : string {
	$t = intval($epoch);
	if ($t > 1) {
		$diff = time() - $t;
		$r = floor($diff / DAY);
		if ($r == 0) { return _("today"); }
		if ($r > 0 && $r < 1024) { return  $r . _(" days ago"); }
	}
	return _("never");
}


/**
 * @param string $text 
 * @return string returns the unmodified $text
 */
function identity(string $text) : string {
	return $text;
}

/**
 * escape user input for display on rendered page
 * @param string $text 
 * @param null|callable $next_fn 
 * @return string 
 */
function escape(string $text) : string {
	return htmlspecialchars($text, ENT_QUOTES, 'UTF-8', false);
}

/**
 * count the number of elements in an array, or 0 for any other input type
 * @param mixed $input 
 * @return string 
 */
function counter($input) : string {
	if (is_array($input)) {
		return count($input);
	}
	return "0";
}



namespace ThreadFin;

use DOMDocument;
use DOMNode;
use DOMXPath;
use ThreadFin\Effect;
use ThreadFin\FileData;
use ThreadFin\FileMod;

use function ThreadFin\partial_right as ƒixr;
use function ThreadFin\debug;
use function ThreadFin\icontains;
use BitFire\Config as CFG;

use const ThreadFin\view\VIEW_ROOT;

const NOT_MINIFY_EXTENSIONS = ["svg", "min"];
const MODIFIER_MAPPING = [
	"-" => "\ThreadFin\\view\\escape",
	"+" => "intval",
	"%d" => "\ThreadFin\\view\\days_format",
	"%u" => "strtoupper",
	"%U" => "ucfirst",
	"%W" => "ucwords",
	"%c" => "ThreadFin\\view\\counter"
];
// CHAR FORMAT matches single character modifiers
const CHAR_FORMAT = "[+-]";
const MAX_VAR_REPLACEMENT = 1000;
const DO_MINIFY = true;


/**
 * extract innerHTML from a node
 * @param DOMNode $element 
 * @return string 
 */
function inner_html(DOMNode $element) : string {
    $innerHTML = "";
    $children  = $element->childNodes;

    foreach ($children as $child) {
        $innerHTML .= $element->ownerDocument->saveHTML($child);
    }

    return $innerHTML;
}


/**
 * pure function to minify HTML.
 * This will remove all newlines excess whitespace and comments.
 * @param string $in 
 * @return string 
 */
function minify_str(string $in) : string {
	//$t1 = preg_replace("/[\s\n]+/m", " ", $in);
	//$t2 = preg_replace("/>\s+</", "><", $t1);
	//$t3 = preg_replace("/<!--.*?-->/", "", $t2);
	$t3 = $in;
	if (empty($t3)) { return ""; }

	if (!class_exists("DOMDocument")) {
		return $in;
	}

	\libxml_use_internal_errors(false);

	// load XML
	$doc = new DOMDocument();
	if (CFG::enabled("debug_file")) {
		$doc->preserveWhiteSpace = false;
		$doc->formatOutput = true;
	}
	$doc->loadHTML($t3, LIBXML_NOWARNING | LIBXML_NOERROR | LIBXML_NOCDATA | LIBXML_NONET);
	foreach (libxml_get_errors() as $error) {
		debug("libxml: %s:%d [%s]", $error->file, $error->line, $error->message);
	}
	
	// find all content to translate
	libxml_clear_errors();
	$xpath = new DOMXpath($doc);
	$elms = $xpath->query("//*[contains(@class,'tdc')]");
	debug("translating %d tags\n", $elms->count());
	for ($i = 0; $i < $elms->count(); $i++) {
		$in = trim(inner_html($elms->item($i)));
		$translated = _($in);
		$t3 = str_replace($in, $translated, $t3);
	}

	return $t3;
}

function chain_fn(callable $fn1, ?callable $fn2 = NULL) : callable {
    return function (...$x) use ($fn1, $fn2) {
        $result = $fn1(...$x);
        if ($fn2 != NULL) {
            $result = $fn2($result);
        }
        return $result;
    };
}


/**
 * add a new string modifier to the list of available view modifiers
 * if $modifier_fn is NULL returns the modifier already mapped to $modifier_name
 * @param null|string $modifier_name 
 * @param null|callable $modifier_fn 
 * @return array 
 */
function view_modifier(string $modifier_name, ?callable $modifier_fn = null) : ?Callable {
	static $mapping = MODIFIER_MAPPING;

	if ($modifier_fn === NULL) {
		$modifiers = explode("|", $modifier_name);

		$idx = 0;
		$fn = $mapping[$modifiers[0]]??NULL;
		while(isset($modifiers[++$idx])) {
			$fn = chain_fn($fn, $mapping[$modifiers[$idx]]??NULL);
		}
		return $fn;
	}

	$mapping[$modifier_name] = $modifier_fn;
	return NULL;
}


/**
 * pure function to minify HTML files.
 * 
 * @param string $filename 
 * @return Effect 
 */
function minify(string $filename) : Effect {
	$effect  = Effect::create();

	$extension = pathinfo($filename, PATHINFO_EXTENSION);
	// just serve minified files directly
	if ($extension === "min") {
		$effect->out(
			FileData::create($filename)->raw()
		);
	}
	// if the file is minify-able
	else if (DO_MINIFY && ! icontains($extension, NOT_MINIFY_EXTENSIONS)) {
		$min_filename = "{$filename}.min";
		$min_file = FileData::create($min_filename);
		// if we have a minified version on disk, serve that

		if ($min_file->exists && filemtime($min_filename) > filemtime($filename)) {
			$effect->out($min_file->raw());
		}
		// read the raw file, minify it and return an effect to write the
		// minified file to disk
		else {
			$source_content = FileData::create($filename)->raw();
			$min_content = minify_str($source_content);
			$effect->out($min_content);
			$effect->file(new FileMod($min_filename, $min_content, \BitFire\FILE_RW));
		}
	}

	return $effect;
}


/**
 * pure function to render the file source with any replacements, src_root unused
 */
function render_file(string $src2, array $replacements = array()) : string {
	$min_effect = minify($src2);
	$content = $min_effect->read_out(true);
	$min_effect->run();
	$rendered = process_line($content, $replacements);
	return $rendered;
}



/**
 * the view variable replacement code.  
 * supports string, array and object access with the dot operator
 * @param array $x 
 * @param array $replacements 
 * @return string 
 */
function content_replacement(array $x, array $replacements) {
	$mod_fn = NULL;
	if (!empty($x[2])) {
		$mod_fn = view_modifier($x[2]);
	}
	$primary = $x[3];
	$secondary = $x[4]??"";

	// replace content.  apply any view modifiers
	if (isset($replacements[$primary])) {
		$return = $replacements[$primary];
		if (!empty($secondary)) {
			if (is_object($return)) {
				if (isset($return->$secondary)) {
					$value = $return->$secondary;
				}
			}
			else if (is_array($return)) {
				if (isset($return[$secondary])) {
					$value = $return[$secondary];
				}
			}
			else {
				$value = (string)$return;	
			}
		}
		else {
			$value = (string)$return;	
		}
		if (!isset($value)) {
			debug("VIEW VAR MISSING %s.%s", $primary, $secondary);
			$value = "";
		}
		return ($mod_fn == NULL) ? $value : $mod_fn($value);
	}

	debug("unset view variable [%s.%s]", $primary, $secondary);
	return "undefined [{$primary}.{$secondary}]";
}

function template_replacement_min(string $template_markup, string $template_var, string $var_name, array $replacements) : string {
	$content = "";
	foreach($replacements[$var_name] as $item) {
		$replacements[$template_var] = $item;
		$content .= process_line($template_markup, $replacements);
	}
	return $content;
}

/**
 * 
 */
function template_replacement(array $x, array $replacements, array $templates) {
	$template_name = $x[1];
	$var_name = $x[2];
	$content = "";
	$template_arr = $templates[$template_name]??[];
	$template_var = $template_arr[0]??$var_name;
	$template_markup = $template_arr[1]??"";
	if (isset($replacements[$var_name])) {
		if (count($replacements[$var_name]) > 0) {
		foreach($replacements[$var_name] as $item) {
			$replacements[$template_var] = $item;
			$content .= process_line($template_markup, $replacements);
		}
	}
	}
	return $content;
}


/**
 * replace all variables in a line with data from replacements, sub render any included content
 * and templates
 */
function process_line(string $line, array $replacements) : string {
	// internal templates
	$templates = [];

	//extract inline templates
	$line = preg_replace_callback(
		"/{{\s*template\s*:\s*(\w+)[\s\:]+[\'\"]?(\w+)[\'\"]?\s*}}\s*(.*?){{\s*end[\s\:]+template\s*}}/mis",
		function($x) use (&$templates) {
			$templates[$x[1]] = [$x[2], $x[3]];
			return "";
	}, $line);

	// replace variable substitution
	$line = preg_replace_callback("/{{\s*((\%[a-zA-Z]|".CHAR_FORMAT."|\|)*)*\s*([_\w-]+)\.?([_\w-]*)\s*}}/", 
	ƒixr("ThreadFin\\content_replacement", $replacements), $line, MAX_VAR_REPLACEMENT);

	// replace inline templates
	$line = preg_replace_callback("/{{\s*render[\s:]+(\w+)\s+[\'\"]?(\w+)[\'\"]?\s*}}/",
	ƒixr("ThreadFin\\template_replacement", $replacements, $templates), $line, MAX_VAR_REPLACEMENT);

	// include sub templates, params will be injected into view variables
	// {{> path/to/view param1="value" param2="value" }}
	$line = preg_replace_callback("/{{>\s*([^\s}]+)\s*([^}]*)}}/", 
		function ($x) use ($replacements) {
			preg_match_all("/([_\w]+)\s*=\s*\"([^\"]*)/", $x[2], $matches);
			$params = array();
			for ($i=0,$m=count($matches[1]);$i<$m;$i++) {
				$params[$matches[1][$i]] = $matches[2][$i];
			}

			$replacements = array_merge($replacements, $params);
			return render_file(VIEW_ROOT . DS . $x[1], $replacements);
		 }, $line, MAX_VAR_REPLACEMENT);

	return $line;
}

