<?php

namespace App\Classes\Protocol;

use App\Models\Address;
use App\Models\Domain;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;

use App\Http\Controllers\DomainController;

final class DNS
{
	private const LOGKEY = 'PD-';

	private string $rx_buf;
	private array $header;
	private array $remote;
	private int $rx_ptr;
	private \Socket $socket;

	// DNS Response codes
	public const DNS_NOERROR = 0;		// No error
	public const DNS_FORMERR = 1;		// Format Error - The dns server could not read / understand the query
	public const DNS_SERVFAIL = 2;		// Server Failure - There was a dns error with the dns server
	public const DNS_NAMEERR = 3;		// Name Error - This specifies that the domain name in the query does not exist, it is only valid from an authoritative server
	public const DNS_NOTIMPLEMENTED = 4;// Not implemented - The requested query is not supported by the dns server
	public const DNS_REFUSED = 5;		// Refused - The dns server refuses to process the dns query

	// DNS Query Classes
	public const DNS_QUERY_IN = 1;		// Internet (this is the main one that is used)

	// DNS Query Types
	public const DNS_TYPE_A	= 1;		// A Records
	public const DNS_TYPE_NS = 2;		// NS Records
	public const DNS_TYPE_CNAME	= 5;	// CNAME Records
	public const DNS_TYPE_SOA = 6;		// SOA Records
	public const DNS_TYPE_MX = 15;		// MX Records
	public const DNS_TYPE_TXT = 16;		// TXT Records

	public const DNS_TYPE_AAAA = 28;	// AAAA Records

	// https://github.com/guyinatuxedo/dns-fuzzer/blob/master/dns.md
	private const header = [				// Struct of a DNS query
		'id'		=> [0x00,'n',1],		// ID
		'header'	=> [0x01,'n',1],		// Header
		'qdcount'	=> [0x02,'n',1],		// Entries in the question
		'ancount'	=> [0x03,'n',1],		// Resource Records in the answer
		'nscount'	=> [0x04,'n',1],		// Server Resource Records in the answer
		'arcount'	=> [0x05,'n',1],		// Resource Records in the addition records section
	];

	/**
	 * Handle a DNS query
	 *
	 * https://www.ietf.org/rfc/rfc1035.txt
	 * https://github.com/guyinatuxedo/dns-fuzzer/blob/master/dns.md
	 *
	 * labels          63 octets or less
	 * names           255 octets or less
	 * TTL             positive values of a signed 32 bit number.
	 * UDP messages    512 octets or less
	 *
	 * @param array $remote
	 * @param string $buf
	 * @param \Socket $socket
	 * @return void
	 *
dig +noedns -t CNAME mail.dcml.au @1.1.1.1

	; <<>> DiG 9.10.6 <<>> +noedns -t CNAME mail.dcml.au @1.1.1.1
	;; global options: +cmd
	;; Got answer:
	;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 6473
	;; flags: qr rd ra ad; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0

	;; QUESTION SECTION:
	;mail.dcml.au.			IN	CNAME

	;; ANSWER SECTION:
	mail.dcml.au.		300	IN	CNAME	l.dege.au.

	;; Query time: 43 msec
	;; SERVER: 1.1.1.1#53(1.1.1.1)
	;; WHEN: Thu Apr 20 06:47:55 PST 2023
	;; MSG SIZE  rcvd: 51
06:47:54.995929 IP 192.168.130.184.51502 > one.one.one.one.domain: 6473+ CNAME? mail.dcml.au. (30)
	0x0000:  cc03 d9cc 88cb bcd0 7414 d055 0800 4500  ........t..U..E.
	0x0010:  003a eee2 0000 4011 466e c0a8 82b8 0101  .:....@.Fn......
	0x0020:  0101 c92e 0035 0026 bb5f|1949 0120 0001  .....5.&._.I....
	0x0030:  0000 0000 0000 046d 6169 6c04 6463 6d6c  .......mail.dcml
	0x0040:  0261 7500 0005 0001                      .au.....
06:47:55.034171 IP one.one.one.one.domain > 192.168.130.184.51502: 6473$ 1/0/0 CNAME l.dege.au. (51)
	0x0000:  bcd0 7414 d055 cc03 d9cc 88cb 0800 4588  ..t..U........E.
	0x0010:  004f 514a 4000 3a11 a969 0101 0101 c0a8  .OQJ@.:..i......
	0x0020:  82b8 0035 c92e 003b 9274|1949 81a0 0001  ...5...;.t.I....
	0x0030:  0001 0000 0000 046d 6169 6c04 6463 6d6c  .......mail.dcml
	0x0040:  0261 7500 0005 0001 c00c 0005 0001 0000  .au.............
	0x0050:  012c 0009 016c 0464 6567 65c0 16         .,...l.dege..
	 */
	public function onConnect(array $remote,string $buf,\Socket $socket)
	{
		Log::debug(sprintf('%s:+ DNS Query',self::LOGKEY));
		$header_len = collect(self::header)->sum(function($item) { return $item[2]*2; });

		$this->rx_buf = $buf;
		$this->rx_ptr = 0;

		// DNS Query header
		$this->header = unpack(self::unpackheader(self::header),$buf);
		$this->rx_ptr += $header_len;

		$this->remote = $remote;
		$this->socket = $socket;

		// If there is no query count, then its an error
		if ($this->header['qdcount'] !== 1) {
			Log::error(sprintf('%s:! DNS query doesnt have the right number of queries [%d]',self::LOGKEY,$this->header['qdcount']));
			$this->reply(self::DNS_FORMERR);
			return;
		}

		// Get the query elements
		$labels = collect();
		while (($len=ord($this->read(1))) !== 0x00)
			$labels->push($this->read($len));

		$this->question = substr($this->rx_buf,$header_len,$this->rx_ptr-$header_len+4);

		// We need a minimum of f.n.z.d.root
		if ($labels->count() < 5) {
			Log::error(sprintf('%s:! DNS query for a resource we dont manage [%s]',self::LOGKEY,$labels->join('.')));
			$this->reply(self::DNS_NAMEERR);

			return;
		}

		// First check that it is a query we can answer
		// First label should be p.. or f..
		if (! is_null($p=$this->parse('p',$labels->first())))
			$labels->shift();

		if (is_null($f=$this->parse('f',$labels->shift())))
			return $this->nameerr($labels);

		if (is_null($n=$this->parse('n',$labels->shift())))
			return $this->nameerr($labels);

		if (is_null($z=$this->parse('z',$labels->shift())))
			return $this->nameerr($labels);

		if (is_null($d=$labels->shift()))
			return $this->nameerr($labels);

		// Make sure we have a root/base domain
		if (! $labels->count())
			return $this->nameerr($labels);

		$rootdn = $labels->join('.');

		$ao = Address::findFTN(sprintf('%d:%d/%d.%d@%s',$z,$n,$f,$p,$d));

		// Check we have the right record
		if ((! $ao) || (! $ao->system->mailer_address) || (($rootdn !== 'ftn') && ((! $ao->zone->domain->dnsdomain) || ($ao->zone->domain->dnsdomain !== $d.'.'.$rootdn))))
			return $this->nameerr($labels);

		// Get the query type/class
		$result = unpack('ntype/nclass',$x=$this->read(4));

		// If the wrong class
		if ($result['class'] !== self::DNS_QUERY_IN) {
			Log::error(sprintf('%s:! We only service Internet queries [%d]',self::LOGKEY,$result['class']));
			$this->reply(self::DNS_NOTIMPLEMENTED);
			return;
		}

		// Check the class
		switch ($result['type']) {
			case self::DNS_TYPE_CNAME:
			case self::DNS_TYPE_A:
			case self::DNS_TYPE_AAAA:
				Log::debug(sprintf('%s:= Returning [%s] for DNS query [%s]',self::LOGKEY,$ao->system->mailer_address,$ao->ftn));
				$this->reply(
					self::DNS_NOERROR,
					$this->question,
					[serialize(explode('.',$ao->system->mailer_address)) => self::DNS_TYPE_CNAME]);
				break;

			default:
				Log::error(sprintf('%s:! We dont support DNS query types [%d]',self::LOGKEY,$result['type']));
				$this->reply(self::DNS_NOTIMPLEMENTED);
		}
	}

	/**
	 * Return a compression string for a specific offset
	 *
	 * @param int $offset
	 * @return string
	 */
	private function compress(int $offset): string
	{
		return pack('n',$offset | (3 << 14));
	}

	private function nameerr(Collection $labels)
	{
		Log::error(sprintf('%s:! DNS query for a resource we dont manage [%s]',self::LOGKEY,$labels->join('.')));

		$this->reply(self::DNS_NAMEERR);
	}

	/**
	 * Parse a label for a fido address nibble
	 *
	 * @param string $prefix
	 * @param string $label
	 * @return string|null
	 */
	private function parse(string $prefix,string $label): ?string
	{
		$m = [];

		return (preg_match('/^'.$prefix.'([0-9]+)+/',$label,$m) && ($m[1] <= DomainController::NUMBER_MAX))
			? $m[1]
			: NULL;
	}

	/**
	 * Return a DNS response
	 *
	 * @param int $code
	 * @param string $question
	 * @param array $answer
	 * @return bool
	 */
	private function reply(int $code,string $question='',array $answer=[]): bool
	{
		$header = (1 << 15);		// 1b: Query/Response
		$header |= (0 << 11);		// 4b: Opcode
		$header |= (0 << 10);		// 1b: Authoritative Answer
		$header |= (0 << 9);		// 1b: Truncated
		$header |= (0 << 8);		// 1b: Recursion Desired (in queries)
		$header |= (0 << 7);		// 1b: Recursion Available (in responses)
		$header |= (0 << 4);		// 3b: Zero (future, should be zero)
		$header |= $code;			// 4b: Result Code

		$q = $question ? 1 : 0;
		$r = count($answer);
		$nscount = 0;
		$arcount = 0;

		$reply = pack('nnnnnn',$this->header['id'],$header,$q,$r,$nscount,$arcount);

		// Return the answer
		if ($r) {
			// Question
			$reply .= $question;

			// @todo In the case we return a CNAME and an A record, this should reference the CNAME domain when returning the A record
			foreach ($answer as $item => $type)
				$reply .= $this->rr($this->compress(12),unserialize($item),$type,300);

		} else {
			$reply .= $question;
		}

		// nscount
		//$reply .= $this->rr($this->domain_split('net1.fsxnet.nz'),["a","root-servers","net"],self::DNS_TYPE_NS,300);

		if (! socket_sendto($this->socket,$reply,strlen($reply),0,(string)$this->remote['ip'],(int)$this->remote['port'])) {
			Log::error(sprintf('%s:! Error [%s] sending DNS reply to [%s:%d]',
				self::LOGKEY,
				socket_strerror(socket_last_error()),
				$this->remote['ip'],
				$this->remote['port']
			));

			return FALSE;
		}

		return TRUE;
	}

	private function domain_split(string $domain): string
	{
		$a = '';

		foreach (explode('.',$domain) as $item)
			$a .= pack('C',strlen($item)).$item;

		return $a;
	}

	/**
	 * Read from a rx_buf
	 *
	 * @param int $len
	 * @return string
	 */
	private function read(int $len): string
	{
		$result = substr($this->rx_buf,$this->rx_ptr,$len);
		$this->rx_ptr += $len;

		return $result;
	}

	/**
	 * Return a DNS Resource Record
	 *
	 * @param string $query	- Domain in the query
	 * @param mixed $ars - Answer resources
	 * @param int $type - Resource type
	 * @param int $ttl - Time to live
	 * @return string
	 */
	private function rr(string $query,mixed $ars,int $type,int $ttl): string
	{
		// Reference the domain query in the question
		$reply = $query;

		// Record Type
		$reply .= pack('n',$type);

		// Internet
		$reply .= pack('n',self::DNS_QUERY_IN);

		$reply .= pack('n',0);

		// TTL
		$reply .= pack('n',$ttl);

		// Answer
		$a = '';
		switch ($type) {
			case self::DNS_TYPE_CNAME:
			case self::DNS_TYPE_NS:
				foreach ($ars as $item)
					$a .= pack('C',strlen($item)).$item;

				$a .= "\x00";
				break;

			case self::DNS_TYPE_A:
			case self::DNS_TYPE_AAAA:
				$a .= $ars;
		}

		$reply .= pack('n',strlen($a)).$a;

		return $reply;
	}

	/**
	 * Unpack our configured DNS header
	 *
	 * @param array $pack
	 * @return string
	 */
	protected static function unpackheader(array $pack): string
	{
		return join('/',
			collect($pack)
				->sortBy(function($k,$v) {return $k[0];})
				->transform(function($k,$v) {return $k[1].$v;})
				->values()
				->toArray()
		);
	}
}