<?php

namespace MGModule\DNSManager2\mgLibs\custom\dns\submodules;

use Exception;
use \MGModule\DNSManager2\mgLibs\custom\dns;
use MGModule\DNSManager2\mgLibs\custom\dns\exceptions\DNSSubmoduleException;
use MGModule\DNSManager2\mgLibs\custom\dns\exceptions\DNSSubmoduleHiddenException;
use \MGModule\DNSManager2\mgLibs\custom\dns\interfaces;
use MGModule\DNSManager2\mgLibs\custom\dns\SubmoduleExceptionCodes;
use MGModule\DNSManager2\mgLibs\custom\dns\utils\Patterns;
use MGModule\DNSManager2\mgLibs\custom\helpers\IdnaHelper;
use MGModule\DNSManager2\mgLibs\custom\helpers\TimeUnitsAliassesHelper;
use phpseclib\Crypt\RSA;
use phpseclib\Net\SFTP;
use phpseclib\Net\SSH2;

/**
 * Class Bind9 support for Bind9 server
 * @package MGModule\DNSManager2\mgLibs\custom\dns\submodules
 */
class Bind9 extends dns\SubmoduleAbstract implements
    interfaces\SubmoduleImportInterface,
    interfaces\SubmoduleTTLInterface
{
    public $configFields = [
        'hostname'       => [
            'friendlyName' => 'Hostname/IP',
            'placeholder'  => 'example.com',
            'validators'   => [
                'required' => 'required',
            ]
        ],
        'username'       => [
            'friendlyName' => 'Username',
            'validators'   => [
                'required' => 'required',
            ]
        ],
        'password'       => [
            'friendlyName' => 'User Password',
            'type'         => 'password',
        ],
        'default_ip'     => [
            'friendlyName' => 'Default IP',
            'placeholder'  => '10.10.10.10',
            'validators'   => [
                'required' => 'required',
                'pattern'  => Patterns::IP4_OR_IP6,
            ]
        ],
        'rsa'            => [
            'friendlyName' => 'RSA Private Key',
            'type'         => 'textarea',
        ],
        'ssl'            => [
            'friendlyName' => 'Enable SSL',
            'type'         => 'yesno',
        ],
        'pathtoconfig'   => [
            'friendlyName' => 'Path To Bind9 Config Directory',
            'placeholder'  => '/etc/bind/',
            'validators'   => [
                'required' => 'required',
            ]
        ],
        'pathtobackup'   => [
            'friendlyName' => 'Path To Backup Directory',
            'placeholder'  => '/etc/bind/backups/',
            'validators'   => [
                'required' => 'required',
            ]
        ],
        'reloadService' => [
            'friendlyName' => 'Reload Service Name',
            'type'         => 'select',
            'options' => [
                'bind'  => 'bind9',
                'named' => 'named'
            ],
            'help'         => 'Required for reloading service after zone activation'
        ],
        'rname'          => [
            'friendlyName' => 'Admin Email (RNAME)',
            'placeholder'  => 'admin.example.com.',
            'validators'   => [
                'required' => 'required'
            ],
        ],
        'refresh'        => [
            'friendlyName' => 'Refresh',
            'placeholder'  => '900',
            'validators'   => [
                'required' => 'required'
            ],
        ],
        'retry'          => [
            'friendlyName' => 'Retry',
            'placeholder'  => '600',
            'validators'   => [
                'required' => 'required'
            ],
        ],
        'expire'         => [
            'friendlyName' => 'Expire',
            'placeholder'  => '86400',
            'validators'   => [
                'required' => 'required'
            ],
        ],
        'notify_slaves'  => [
            'friendlyName' => 'Notify Slaves',
            'type'         => 'yesno',
        ],
        'master_ip'      => [
            'friendlyName' => 'Master IP',
            'placeholder'  => '10.10.10.10',
            'help'         => 'Required when notify slaves are active'
        ],
        'slaves'         => [
            'friendlyName' => 'Slaves IP List',
            'type'         => 'textarea',
            'placeholder'  => '10.10.10.4, 10.10.10.5, 10.10.10.6'
        ],
        'customSettings' => [
            'friendlyName' => 'Additional Settings',
            'type'         => 'textarea',
            'help'         => 'It will be placed in named.conf.local as plain text make sure your format is valid',
            'placeholder'  => " max-journal-size 50k;\n min-retry-time 100;"
        ]
    ];

    public $availableTypes = ['A', 'AAAA','AFSDB','ALIAS', 'CAA', 'CNAME', 'DNAME','DS', 'HINFO', 'NS', 'MX',  'TXT', 'SRV', 'NAPTR', 'RP', 'SOA'];
    private $soa = [];

    /**
     * @var SSH2
     */
    private $ssh2;

    /**
     * @var SFTP
     */
    private $sftp;

    /**
     * @var string
     */
    private $bindConfigFile;

    /**
     * @var string
     */
    private $zoneFilePath;

    /**
     * @var string
     */
    private $zoneFile;

    /**
     * @var array
     */
    private $records;
    /**
     * @var int
     */
    private $ttl;

    /**
     * Test connection with Server
     * @throws DNSSubmoduleException if fails to connect
     */
    public function testConnection()
    {
        if ( !$this->ssh2 ) $this->establishSSH2Connection();

        if ( !$this->fileExists($this->parsePathToConfig()) )
        {
            throw new DNSSubmoduleException('File: ' . $this->parsePathToConfig() . ' doesn\'t exists', SubmoduleExceptionCodes::CONNECTION_PROBLEM);
        }
        if ( !$this->directoryExists($this->parsePathToConfig(true) . 'zones') && !$this->createDirectory($this->parsePathToConfig(true) . 'zones') )
        {
            throw new DNSSubmoduleException('This module requires writable directory: ' . $this->parsePathToConfig(true) . 'zones');
        }
        if ( !$this->directoryExists($this->config['pathtobackup']) )
        {
            throw new DNSSubmoduleException('Directory: ' . $this->config['pathtobackup'] . ' doesn\'t exists', SubmoduleExceptionCodes::CONNECTION_PROBLEM);
        }
        $tempFileName = $this->parsePathToConfig(true) . uniqid('dnsm2temp', false);

        if ( $this->upload($tempFileName, 'Lorem ipsum') )
        {
            $this->removeFile($tempFileName);
        }
        else
        {
            throw new DNSSubmoduleException('Account you specified don\'t have read/write prmissions in bind directory', SubmoduleExceptionCodes::CONNECTION_PROBLEM);
        }

        return true;
    }

    /**
     * Gets records from server
     * @param bool $recordTypeFilter
     * @return array
     * @throws DNSSubmoduleException
     */
    public function getRecords( $recordTypeFilter = false )
    {
        $this->records = $this->records ? : $this->parseFileToStructure($recordTypeFilter);

        return $this->records;
    }

    /**
     * Adds a record to server
     * @param dns\record\Record $record
     * @return bool
     * @throws DNSSubmoduleException
     * @throws DNSSubmoduleHiddenException
     */
    public function addRecord( dns\record\Record $record )
    {
        //Since addRecord can be called in loop we need to download last good configuration every run
        //So when somenone will try to create wrong record the function will revert file one step back instead of to point when loop started
        $this->updateZoneVariables();

        //We just add this to structure and parse it later so no need to do it multiple times since it will give same result
        if ( !$this->records )
        {
            $this->records = $this->parseFileToStructure();
        }

        if(($record->type === 'SOA') && ($soaRecordPositon = array_search('SOA', array_column($this->records, 'type'), true)) !== false)
        {
            $record->line = $this->records[$soaRecordPositon]->line;
            return $this->editRecord($record);
        }

        $record->name = $this->replaceNameToBind9Record($record->name);
        if ( !$this->validateName($record->name) )
        {
            throw new DNSSubmoduleHiddenException('The Record ' . $record->name . ' name is invalid and can\'t be saved');
        }
        //Before adding we should check if the record is valid
        $record->rdata->validate();

        $this->records[] = $record;

        $this->backupConfig($this->bindConfigFile);
        $this->backupZone($this->zoneFile);

        $this->incrementSOASerial();

        $this->upload($this->zoneFilePath, $this->parseStructureToFile());

        $zoneValidation = $this->checkZoneChanges();

        if ( $zoneValidation !== true )
        {
            $this->upload($this->zoneFilePath, $this->zoneFile);
            throw new DNSSubmoduleHiddenException($record->__toString() .' -> '.$zoneValidation);
        }

        $this->reloadZone();
        return false;
    }

    /**
     * Edits record by matching it with line
     * @param dns\record\Record $record
     * @return bool
     * @throws DNSSubmoduleException
     * @throws DNSSubmoduleHiddenException
     */
    public function editRecord( dns\record\Record $record )
    {
        if ( !$this->zoneFile )
        {
            $this->updateZoneVariables();
        }

        $record->name = $this->replaceNameToBind9Record($record->name);

        if ( !$this->validateName($record->name) )
        {
            throw new DNSSubmoduleHiddenException('Invalid Record Name: ' . $record->name);
        }

        //Validate data sent by user
        $record->rdata->validate();

        if ( !$this->records )
        {
            $this->records = $this->parseFileToStructure();
        }

        /** @var dns\record\Record $structRecord */
        foreach ( $this->records as $index => $structRecord )
        {
            if ( (int)$structRecord->line === (int)$record->line )
            {
                $record->ttl           = $record->ttl ? : 14400;
                $this->records[$index] = $record;
            }
        }

        $this->backupConfig($this->bindConfigFile);
        $this->backupZone($this->zoneFile);

        $this->incrementSOASerial();

        if ( !$this->upload($this->zoneFilePath, $this->parseStructureToFile()) ) throw new DNSSubmoduleException('We were unable to edit record: ' . $record->type . ' in file: ' . $this->zoneFilePath);

        $zoneValidation = $this->checkZoneChanges();

        if ( $zoneValidation !== true )
        {
            $this->upload($this->zoneFilePath, $this->zoneFile);
            throw new DNSSubmoduleException($zoneValidation);
        }

        $this->reloadZone();
        return true;
    }

    /**
     * Removes record by it's line
     * @param dns\record\Record $record
     * @throws DNSSubmoduleException
     */
    public function deleteRecord( dns\record\Record $record )
    {
        if ( !$this->zoneFilePath )
        {
            $this->updateZoneVariables();
        }

        if ( !$this->records )
        {
            $this->records = $this->parseFileToStructure();
        }

        /** @var dns\record\Record $structRecord */
        foreach ( $this->records as $index => $structRecord )
        {
            if ( (int)$structRecord->line === (int)$record->line )
            {
                unset($this->records[$index]);
            }
        }

        $this->backupConfig($this->bindConfigFile);
        $this->backupZone($this->zoneFile);

        $this->incrementSOASerial();

        $this->upload($this->zoneFilePath, $this->parseStructureToFile());

        $this->reloadZone();
    }

    /**
     * Checks if zone exists
     * @return bool
     * @throws DNSSubmoduleException
     */
    public function zoneExists()
    {
        return array_key_exists($this->getDomainWithoutDot(), $this->getZones());
    }

    public function activateZone()
    {
        $configFile   = $this->downloadFile($this->parsePathToConfig());
        $zoneFileName = $this->parsePathToConfig(true);
        $zoneFileName .= 'zones/db.' . $this->getDomainWithoutDot();

        //Get template config file and parse
        $path = __DIR__ . '/bind9/sampleZoneMasterConfigFile.txt';

        $zoneConfigString = file_get_contents($path);

        if ( $this->config['notify_slaves'] === 'on' && trim($this->config['slaves']))
        {
            $notifyStatus  = 'yes';
            $slaveIpsArray = explode(',', $this->config['slaves']);
            $slaveIps      = implode(';', $slaveIpsArray) . ((count($slaveIpsArray)) ? ';' : '');
        }
        else
        {
            $notifyStatus = 'no';
            $slaveIps     = '';
        }

        $zoneConfigString = str_replace(
            [
                '{$domain}',
                '{$file}',
                '{$notify}',
                '{$slaveips}',
                '{$clientCustom}'
            ],
            [
                $this->getDomainWithoutDot(),
                $zoneFileName,
                $notifyStatus,
                $slaveIps,
                $this->config['customSettings']
            ],
            $zoneConfigString);

        $configFile .= $zoneConfigString;
        //Get template zone file and parse
        $path           = __DIR__ . '/bind9/sampleZoneFile.txt';
        $zoneFileString = file_get_contents($path);
        $ipReplacement  = filter_var($this->ip) ? $this->ip : $this->config['default_ip'];
        $zoneFileString = str_replace(
            [
                '{$domain}',
                '{$ttl}',
                '{$mname}',
                '{$rname}',
                '{$refresh}',
                '{$retry}',
                '{$expire}',
                '{$ip}'
            ],
            [
                $this->getDomainReplacement(),
                14400,
                $this->getDomainReplacement($this->server->getNameservers(1)->name),
                $this->config['rname'],
                $this->config['refresh'],
                $this->config['retry'],
                $this->config['expire'],
                $ipReplacement
            ],
            $zoneFileString);

        $this->backupConfig($configFile);
        $pathToConfig = $this->parsePathToConfig();

        if ( !$this->upload($pathToConfig, $configFile) ) throw new DNSSubmoduleException('We couldn\'t create file: ' . $pathToConfig);
        if ( !$this->upload($zoneFileName, $zoneFileString) ) throw new DNSSubmoduleException('We couldn\'t create file: ' . $zoneFileName);

        $this->reloadBind9();

        $this->synchronizeSlaves($this->getDomainWithoutDot());
    }

    /**
     * @param string $domain
     * @param string $type
     * @return bool
     * @throws DNSSubmoduleException
     */
    protected function synchronizeSlaves( $domain, $type = 'Activate' )
    {
        if(!trim($this->config['slaves'])) return;

        $slaves = explode(',', $this->config['slaves']);

        foreach ( $slaves as $slaveIp )
        {
            $slaveIp = trim($slaveIp);
            if(!filter_var($slaveIp,FILTER_VALIDATE_IP))
            {
                continue;
            }

            $this->establishSFTPConnection(trim($slaveIp));
            $this->establishSSH2Connection(trim($slaveIp));

            $configFile   = $this->downloadFile($this->parsePathToConfig());
            $zoneFileName = 'db.' . $domain;

            if ( $type === 'Activate' )
            {
                $path             = __DIR__ . '/bind9/sampleZoneSlaveConfigFile.txt';
                $zoneConfigString = file_get_contents($path);
                $zoneConfigString = str_replace(
                    [
                        '{$domain}',
                        '{$masterIp}',
                        '{$file}'
                    ],
                    [
                        $domain,
                        $this->config['master_ip'],
                        $zoneFileName
                    ], $zoneConfigString);

                $configFile .= $zoneConfigString;

                $this->upload($this->parsePathToConfig(), $configFile);
            }
            elseif ( $type === 'Terminate' )
            {
                $regexDomain = '/zone\s+"' . preg_quote($domain, '/') . '\.*"\s+.+?\n};/msi';
                $configFile  = preg_replace($regexDomain, '', $configFile, 1);
                $this->upload($this->parsePathToConfig(), $configFile);
                $this->removeFile('/var/cache/bind/db.'.$this->getDomainWithoutDot());
            }
            $this->reloadBind9();
        }

        $this->sftp = null;
        $this->ssh2 = null;

        return true;
    }

    /**
     * Removes zone
     * @return bool
     * @throws DNSSubmoduleException
     */
    public function terminateZone()
    {
        $this->updateZoneVariables();

        if ( !$this->zoneFilePath )
        {
            throw new DNSSubmoduleException('Missing file property in bind9 zone config file');
        }

        //Remove from main file
        $regexDomain = '/zone\s+"' . preg_quote($this->getDomainWithoutDot(), '/') . '\.*"\s+.+?\n};/msi';
        $configFile  = preg_replace($regexDomain, '', $this->bindConfigFile, 1);

        $this->backupConfig($configFile);
        $this->backupZone($this->zoneFile);

        //Remove zone file
        if ( !$this->removeFile($this->zoneFilePath) )
        {
            throw new DNSSubmoduleException('Can\'t delete zone file wrong file or no permissions');
        }

        //Remove zone from config
        $pathToConfig = $this->parsePathToConfig();
        if ( !$this->upload($pathToConfig, $configFile) ) throw new DNSSubmoduleException('We couldn\'t remove zone from config file in path: ' . $pathToConfig);

        $this->synchronizeSlaves($this->getDomainWithoutDot(), 'Terminate');
        $this->reloadBind9();

        return true;
    }

    /**
     * Gets Zones
     * @return array
     * @throws DNSSubmoduleException
     */
    public function getZones()
    {
        $zoneFile = $this->downloadFile($this->parsePathToConfig());

        $values = explode("\n", $zoneFile);
        $out    = [];
        foreach ( $values as $nol => $value )
        {
            if ( preg_match('/in-addr\.arpa/', $value) )
            {
                continue;
            }
            if ( !preg_match('/zone\s"(.+)"/', $value, $domain) )
            {
                continue;
            }
            if ( !filter_var($domain[1], FILTER_VALIDATE_DOMAIN) )
            {
                continue;
            }
            $domain[1]       = rtrim($domain[1], '.');
            $out[$domain[1]] = '';
        }

        return $out;
    }

    /**
     * Uploads string to file specified in first parameter to server
     *
     * @param string $fileLoc    Location of the remote file
     * @param string $stringFile String with \n sepearation to be placed in file
     *
     * @return bool
     * @throws DNSSubmoduleException if can't connect to host
     */
    private function upload( $fileLoc, $stringFile )
    {
        if ( !$this->sftp ) $this->establishSFTPConnection();

        return $this->sftp->put($fileLoc, $stringFile, SFTP::SOURCE_STRING);
    }

    /**
     * Reloads the zone on remote
     * @return boolean
     * @throws DNSSubmoduleException
     */
    private function reloadZone()
    {
        if ( !$this->ssh2 ) $this->establishSSH2Connection();

        return $this->ssh2->exec('rndc reload ' . $this->getDomainWithoutDot());
    }

    /**
     * Reloads bind 9 (required in zone activation)
     * @return string
     * @throws DNSSubmoduleException
     */
    private function reloadBind9()
    {
        if( !$this->ssh2 ) $this->establishSSH2Connection();

        $command = 'systemctl reload ';

        if( $this->config['reloadService'] === 'named' )
        {
            $command .= 'named';
        }
        else
        {
            $command .= 'bind9';
        }

        return $this->ssh2->exec($command);
    }

    /**
     * Checks if file exists on remote
     * @param string $file file
     * @return bool
     * @throws DNSSubmoduleException if couldn't connect
     */
    private function fileExists( $file )
    {
        if ( !$this->sftp ) $this->establishSFTPConnection();
        return ($this->sftp->get($file) !== false);
    }

    /**
     * Removes file from remote
     * @param string $fileLocation Location of the file to be removed
     *
     * @return bool
     * @throws DNSSubmoduleException when he can't find file or have no permissions
     */
    private function removeFile( $fileLocation )
    {
        if ( !$this->sftp ) $this->establishSFTPConnection();
        return $this->sftp->delete($fileLocation);
    }

    /**
     * Gets file from server
     * @param string $file downlaods file from remote
     * @return mixed
     * @throws DNSSubmoduleException
     */
    private function downloadFile( $file )
    {
        if ( !$this->sftp ) $this->establishSFTPConnection();
        return $this->sftp->get($file);
    }

    /**
     * Checks if file exists on remote
     * @param string $dir absolute dir
     * @return bool
     * @throws DNSSubmoduleException
     */
    private function directoryExists( $dir )
    {
        if ( !$this->sftp ) $this->establishSFTPConnection();
        return $this->sftp->chdir($dir);
    }

    /**
     * Creates Directory
     * @param string $dir
     * @return bool depending on result
     * @throws DNSSubmoduleException if couldn't connect to remote
     */
    private function createDirectory( $dir )
    {
        if ( !$this->sftp ) $this->establishSFTPConnection();
        return $this->sftp->mkdir($dir);
    }

    /**
     * Backups the zone file to backup/zones/ directory
     * @param $data
     * @return bool
     * @throws DNSSubmoduleException
     */
    private function backupZone( $data )
    {
        if ( !$this->sftp ) $this->establishSFTPConnection();

        $backupDir = rtrim($this->config['pathtobackup'], '/') . '/zones/';

        if ( !$this->directoryExists($backupDir) )
        {
            $this->createDirectory($backupDir);
        }

        $domain    = str_replace('.', '', $this->getDomainWithoutDot());
        $backupDir .= $domain . '/';
        if ( !$this->directoryExists($backupDir) )
        {
            $this->createDirectory($backupDir);
        }

        $filename = date('YmdHis') . '.txt';

        return $this->upload($backupDir . $filename, $data);
    }

    /**
     * Backups the config file to backup/config
     * @param $data
     * @return bool
     * @throws DNSSubmoduleException
     */
    private function backupConfig( $data )
    {
        if ( !$this->sftp ) $this->establishSFTPConnection();

        $backupDir = rtrim($this->config['pathtobackup'], '/') . '/config/';

        if ( !$this->directoryExists($backupDir) && !$this->createDirectory($backupDir) )
        {
            throw new DNSSubmoduleException('We couldn\'t create diretory for backup config');
        }

        $filename = date('YmdHis') . '.txt';

        return $this->upload($backupDir . $filename, $data);
    }

    /**
     * Parses the config path given by user to valid one
     * @param bool $dir Specifies should function return the dir name or file name
     * @return string
     */
    public function parsePathToConfig( $dir = false )
    {
        $path = $this->config['pathtoconfig'];

        if ( $dir ) return rtrim(str_replace('named.conf.local', '', $path), '/') . '/';

        //If there is no slash at the end and it's not config file we add slash
        if ( substr($path, -1, 1) !== '/' && strpos($path, 'named.conf.local') === false )
        {
            $path .= '/';
        }
        //If there is no name of config at the end we add the default
        if ( strpos($path, 'named.conf.local') === false )
        {
            $path .= 'named.conf.local';
        }

        return $path;
    }

    /**
     * Adds to array of soa properties property which will be later used for creating record
     *
     * @param mixed $soaProp Property to be addded
     * @throws DNSSubmoduleException if there is to many elements in array
     */
    private function addSOAInformation( $soaProp )
    {
        $this->soa[] = $soaProp;
        if ( count($this->soa) > 12 )
        {
            throw new DNSSubmoduleException('There is problably missing ")" in your SOA record definition');
        }
    }

    /**
     * Creates SOA record from properties previously addded to $this->soa
     *
     * @param int|string $defTtl Default ttl of zone if not specified soa ttl will be set to 14400
     *
     * @return dns\record\Record
     * @throws DNSSubmoduleException
     */
    public function createSOAFromArray( $defTtl = 14400 )
    {
        $recordTypeIndex = $this->getIndexOfRecordType($this->soa, 'SOA');
        $record          = new dns\record\Record();
        $record->name    = $this->soa[0];
        $record->type    = 'SOA';

        $ttlAndClass = $this->getTTLAndClassFromValues($recordTypeIndex, $this->soa, $defTtl);

        $record->ttl   = $ttlAndClass['ttl'];
        $record->class = $ttlAndClass['class'];

        //Make sure we have name|class|ttl to ommit later checking for it
        if ( $recordTypeIndex === 2 )
        {
            array_splice($this->soa, 2, 1, [$record->class, $record->ttl]);
        }

        $soaRecord          = new dns\record\type\SOA();
        $soaRecord->mname   = $this->soa[4];
        $soaRecord->rname   = $this->soa[5];
        $soaRecord->serial  = TimeUnitsAliassesHelper::convertToSeconds($this->soa[6]);
        $soaRecord->refresh = TimeUnitsAliassesHelper::convertToSeconds($this->soa[7]);
        $soaRecord->retry   = TimeUnitsAliassesHelper::convertToSeconds($this->soa[8]);
        $soaRecord->expire  = TimeUnitsAliassesHelper::convertToSeconds($this->soa[9]);
        $soaRecord->minimum = TimeUnitsAliassesHelper::convertToSeconds($this->soa[10]);

        $record->rdata      = $soaRecord;
        $record->customData = $record->rdlength = '';
        $record->line       = $this->soa['line'];
        return $record;
    }

    /**
     * Gets location of zone file from main config of bind
     * @param string $bindConfigFile bindConfigFile in string downloaded from bind server
     * @param string $zoneName       Name of zone to search
     *
     * @return string
     * @throws DNSSubmoduleException if can't find zone or when there is no path specified
     */
    private function getZoneFileLocationFrombindConfigFile( $bindConfigFile, $zoneName )
    {
        $preg = '/zone\s+"' . preg_quote($zoneName, '/') . '\.*".+?\n};/msi';

        if ( !preg_match($preg, $bindConfigFile, $matches) )
        {
            throw new DNSSubmoduleException('We couldn\'t find ' . $zoneName . ' in your bindconfig file');
        }
        if ( !preg_match('/file\s"(.+?)"/mi', $matches[0], $zonePath) )
        {
            throw new DNSSubmoduleException('We couldn\'t find path in your ' . $zoneName . ' config section in your bind config file');
        }

        return $zonePath[1];
    }

    /**
     *  Convert Time aliasses to normal int seconds
     * @param $line
     *
     * @return string|string[]|null
     * @throws Exception
     */
    private function convertTime( $line )
    {
        $values = preg_split('/\s+/', $line);
        foreach ( $values as &$value )
        {
            if ( preg_match('/(\d+)([MHDW])/i', $value, $match) )
            {
                $replacement = TimeUnitsAliassesHelper::convertToSeconds($match[0]);
                $value       = preg_replace('/(\d+)([MHDW])/i', $replacement, $value);
            }
        }
        return implode(' ', $values);
    }

    /**
     * Replaces @ and whitespaces to domain names with dot attached
     * @param string $line
     * @return string
     */
    private function replaceName( $line )
    {
        //Explode line to array
        $values = preg_split('/\s+/', $line);

        //Record name is blank/whitespace/@ so we remove place there zone name
        if ( $values[0] === '@' || !trim($values[0]) )
        {
            $values[0] = $this->getDomainReplacement();
            $line      = implode(' ', $values);
        }

        return $line;
    }

    /**
     * Creates record name with @ if record name matches domain and trims domain from the end
     * @param string $name
     * @return string
     */
    private function replaceNameToBind9Record( $name )
    {
        $name = trim(preg_replace('/\.?' . preg_quote(IdnaHelper::idnaDecode($this->getDomainWithoutDot())) . '\.+$/', '', $name, 1));
        return $name === '' ? '@' : $name;
    }

    /**
     * Filters the records by their type
     * @param array  $lines
     * @param string $recordTypeFilter
     * @return array
     */
    private function filterRecords( $lines, $recordTypeFilter )
    {
        foreach ( $lines as $nol => $line )
        {
            //Explode line to array
            $values = preg_split('/\s+/', $line);

            //Get record type from array
            $recordType = $this->getRecordType($values);

            //If filter is set and record doesn't match we remove it
            if ( $recordType !== $recordTypeFilter )
            {
                unset($lines[$nol]);
                continue;
            }
        }

        return $lines;
    }

    /**
     * Removes everything in line after ";"
     * @param string $line
     * @return false|string
     */
    private function removeComments( $line )
    {
        //Replaces everything after ; but only if semicolon is not between quotation marks
        //I spent on it about 1 hour plz don't remove
        return preg_replace('/(?:^\s*;|(?:;(?!.+"))).+/', '', $line);
    }

    /**
     * Returns the index of type property in record
     * @param $values
     * @param $recordType
     * @return false|int|string
     */
    private function getIndexOfRecordType( $values, $recordType )
    {
        return array_search($recordType, $values, false);
    }

    /** Returns record type
     * @param array $values exploded line by \s
     * @return bool
     */
    private function getRecordType( $values )
    {
        foreach ( $values as $value )
        {
            if ( in_array($value, $this->availableTypes, false) )
            {
                return $value;
            }
        }
        return false;
    }

    /**
     * Function looks for $TTL in file
     * @param array $values exploded zone file by "\n"
     * @return int|null
     */
    private function findDefaultTtl( array $values )
    {
        $ttl = null;
        foreach ( $values as $line )
        {
            if ( !preg_match('/\$TTL\s+(\d+)/', $line, $match) )
            {
                continue;
            }
            $ttl = (int)$match[1];
            break;
        }

        return $ttl;
    }

    /** Gets SOA record from zone file
     * @param array $lines
     * @param int   $ttl
     * @return dns\record\Record|null
     * @throws DNSSubmoduleException
     */
    private function getSoaRecord( array $lines, $ttl = 14400 )
    {
        $out = null;

        foreach ( $lines as $nol => $line )
        {
            $values     = preg_split('/\s+/', $line);
            $recordType = $this->getRecordType($values);
            if ( $recordType === 'SOA' )
            {
                //Check if SOA is multiline
                $multilineSoa      = strpos($line, '(') !== false && strpos($line, ')') === false;
                $this->soa['line'] = (int)$nol;
                break;
            }
        }

        if ( !isset($this->soa['line']) ) return $out;

        if ( isset($multilineSoa) )
        {
            $lines[$this->soa['line']] = $this->replaceName($lines[$this->soa['line']]);
            if ( $multilineSoa )
            {
                //We don't know length of SOA so we go from it's beggining to the end of the file
                foreach ( range($this->soa['line'], count($lines) - 1) as $i )
                {
                    //Spliting by whitespace
                    $values = preg_split('/\s+/', $lines[$i]);
                    //We add all values from line if for some reason someone specified more than one
                    foreach ( $values as $soaProp )
                    {
                        if ( ($soaProp === '(') || !trim($soaProp) )
                        {
                            continue;
                        }
                        if ( $soaProp !== ')' )
                        {
                            $this->addSOAInformation($soaProp);
                        }
                        else
                        {
                            $out = $this->createSOAFromArray($ttl);
                        }
                    }
                    //We found end of the soa we can leave
                    if ( strpos($lines[$i], ')') !== false )
                    {
                        break;
                    }
                }
            }
            else
            {
                //Spliting by whitespace
                $values = preg_split('/\s+/', $lines[$this->soa['line']]);
                //We add all values from line
                foreach ( $values as $soaProp )
                {
                    if ( $soaProp !== '(' && $soaProp !== ')' && $soaProp && $soaProp !== ' ' )
                    {
                        $this->addSOAInformation($soaProp);
                    }
                }
                $out = $this->createSOAFromArray($ttl);
            }
        }

        return $out;
    }

    /** Removes comments, empty lines,$ORIGIN and $TTL lines and converts time aliases to seconds
     * @param array $lines
     * @return array
     * @throws Exception when can't convert time
     */
    private function setAndRemoveUsefullValues( $lines )
    {
        foreach ( $lines as $nol => $line )
        {
            if ( !trim($line) )
            {
                unset($lines[$nol]);
                continue;
            }

            $lines[$nol] = $this->removeComments($line);
            if ( strpos($line, '$ORIGIN') !== false )
            {
                unset($lines[$nol]);
                continue;
            }
            if ( preg_match('/\$TTL\s+(\d+)/', $line, $match) )
            {
                $this->ttl = (int)$match[1];
                unset($lines[$nol]);
                continue;
            }
            //All time aliasses to seconds
            $lines[$nol] = $this->convertTime($lines[$nol]);
        }
        return $lines;
    }

    /**
     * Generate the bind9 file from structure
     * @return string
     */
    private function parseStructureToFile()
    {
        $recordsArray = [
            '$ORIGIN ' . $this->getDomainReplacement(),
            '$TTL ' . $this->ttl
        ];

        usort($this->records, static function ( $a, $b )
        {
            $recordAllignment = ['SOA', 'A', 'AAAA', 'NS', 'MX', 'TXT'];
            if ( !in_array($a->type, $recordAllignment, true) && !in_array($b->type, $recordAllignment, true) ) return 0;
            if ( !in_array($a->type, $recordAllignment, true) ) return 1;
            if ( !in_array($b->type, $recordAllignment, true) ) return -1;

            if ( $a->type === $b->type )
            {
                return $a->name < $b->name ? -1 : 1;
            }

            return array_search($a->type, $recordAllignment, true) < array_search($b->type, $recordAllignment, true) ? -1 : 1;
        });

        /** @var dns\record\Record $record */
        foreach ( $this->records as $record )
        {
            $record->name = $this->replaceNameToBind9Record($record->name);

            $recordArray  = [
                $record->name,
                $record->class,
                $record->ttl,
                $record->type
            ];
            $recordRdata =  $record->rdata->toString();

            if( strlen($recordRdata) > 255 )
            {
                $recordsArray = array_merge($recordsArray, $this->splitMultilineRecord($recordArray,$recordRdata));
            }
            else
            {
                $recordsArray[] = implode(' ', $recordArray) . ' ' .$recordRdata;
            }

        }

        return implode("\n", $recordsArray) . "\n";
    }

    /**
     * Removes soa from file checking for it beeing multiline
     *
     * @param array $lines  file sp
     * @param int   $offset the location of soa in lines
     * @return array
     */
    private function removeSoaFromLine( array $lines, $offset = 0 )
    {
        $multilineSoa = (strpos($lines[$offset], '(') !== false) && (strpos($lines[$offset], ')') === false);
        if ( $multilineSoa )
        {
            //Removes lines until finds closing round bracket
            foreach ( range($offset, count($lines) - 1) as $i )
            {
                $templn = $lines[$i];
                unset($lines[$i]);
                if ( strpos($templn, ')') !== false )
                {
                    break;
                }
            }
        }
        else
        {
            unset($lines[$offset]);
        }

        return $lines;
    }

    /**
     * Function checks if there is $ORIGIN in file and matches zone name
     * Not used cause of the $ORIGIN beeing not required in file
     * @param $lines
     * @return mixed|null
     * @throws DNSSubmoduleException
     */
    private function validateFile( $lines )
    {
        $name = null;
        foreach ( $lines as $line )
        {
            if ( !preg_match('/\$ORIGIN\s+(.+)/', $line, $match) )
            {
                continue;
            }
            $name = $match[1];
            break;
        }
        if ( !$name )
        {
            throw new DNSSubmoduleException('Missing $ORIGIN in your file');
        }
        if ( $name !== $this->getDomainWithoutDot() && $name !== $this->getDomainReplacement() )
        {
            throw new DNSSubmoduleException('$ORIGIN in file doesn\'t match the zone name');
        }

        return $name;
    }

    /**
     * Since it's ain't easy task here is function for it
     * @param int    $indexOfRecordType
     * @param array  $values
     * @param int    $defaultTTL
     * @param string $defaultClass
     * @return array
     * @throws DNSSubmoduleException
     */
    private function getTTLAndClassFromValues( $indexOfRecordType, array $values, $defaultTTL, $defaultClass = 'IN' )
    {
        $out = [];
        //We have to check where is ttl and class cause they can be shuffled or one of them can be ommited
        if ( $indexOfRecordType === 3 )
        {
            if ( is_numeric(trim($values[2])) )
            {
                $out['class'] = $values[1];
                $out['ttl']   = (int)$values[2];
            }
            else
            {
                $out['class'] = $values[2];
                $out['ttl']   = (int)$values[1];
            }
        }
        elseif ( $indexOfRecordType === 2 )
        {
            //If there is only 2 we will have to check which one
            if ( is_numeric(trim($values[1])) )
            {
                $out['ttl']   = (int)$values[1];
                $out['class'] = $defaultClass;
            }
            else
            {
                $out['class'] = $values[1];
                $out['ttl']   = (int)$defaultTTL;
            }
        }
        else
        {
            throw new DNSSubmoduleException('Error while processing your record: ' . $values[0] . ' your record values are in wrong order');
        }
        return $out;
    }

    /**
     * Validates the domain name if there is no dot it wasn't the propper domain name
     * @param $name
     * @return bool
     */
    private function validateName( $name )
    {
        return substr($name, -1, 1) !== '.';
    }

    /**
     * @param array $lines   lines of file
     * @param int   $line    number of line to edit
     * @param int   $counter specifies offset from which start removing files
     * @return array with unset lines
     * @throws DNSSubmoduleException if we couldn't find the ")" in file
     */
    private function clearMultiline( $lines, $line, $counter = 0 )
    {
        //Make sure to don't look for lines that are not in file
        $linesToEndOfFile = count($lines) - $line;

        while ( $counter < $linesToEndOfFile )
        {
            if ( $counter > 10 )
            {
                throw new DNSSubmoduleException('Missing closing round bracket in zone file in SOA record you tried to edit');
            }
            //We have to save it somewhere before deleting it
            $currentLine = $lines[$line + $counter];

            unset($lines[$line + $counter]);
            if ( strpos($currentLine, ')') !== false )
            {
                break;
            }
            $counter++;
        }
        return $lines;
    }

    /**
     * For matching and replacing purposes returns domain name with dot attached
     * @param null|string $domain If provided function will append dot to parameter domain instead of global zone
     * @return string
     */
    private function getDomainReplacement( $domain = null )
    {
        $targetDomain = $domain ? : $this->domain;
        return rtrim($targetDomain, '.') . '.';
    }

    /**
     * For matching purposes removes dot (if exist) from the end of the domain
     * @param null|string $domain If provided function will trim dot from parameter domain instead of global zone
     * @return string
     */
    private function getDomainWithoutDot( $domain = null )
    {
        $targetDomain = $domain ? : $this->domain;
        return rtrim($targetDomain, '.');
    }

    /**
     * Sets path to zone and config and downloads file
     * @return bool
     * @throws DNSSubmoduleException
     */
    private function updateZoneVariables()
    {
        $this->bindConfigFile = $this->downloadFile($this->parsePathToConfig());
        $this->zoneFilePath   = $this->getZoneFileLocationFrombindConfigFile($this->bindConfigFile, $this->getDomainWithoutDot());
        $this->zoneFile       = $this->downloadFile($this->zoneFilePath);

        return true;
    }

    /**Creates SSH2 instance
     * @param null $ip
     * @return SSH2
     * @throws DNSSubmoduleException
     */
    private function establishSSH2Connection( $ip = null )
    {
        $connctIp   = $ip ? : $this->config['hostname'];
        $this->ssh2 = new SSH2($connctIp);
        if ( $this->config['rsa'] )
        {
            $auth = new RSA();
            $auth->loadKey($this->config['rsa']);
        }
        else
        {
            $auth = $this->config['password'];
        }
        if ( !$this->ssh2->login($this->config['username'], $auth) )
        {
            throw new DNSSubmoduleException('Wrong authentication details');
        }
        return $this->ssh2;
    }

    /** Creates SFTP Instance
     * @param null $ip
     * @return SFTP
     * @throws DNSSubmoduleException
     */
    private function establishSFTPConnection( $ip = null )
    {
        $connctIp   = $ip ? : $this->config['hostname'];
        $this->sftp = new SFTP($connctIp);

        if ( $this->config['rsa'] )
        {
            $auth = new RSA();
            $auth->loadKey($this->config['rsa']);
        }
        else
        {
            $auth = $this->config['password'];
        }

        if ( !$this->sftp->login($this->config['username'], $auth) )
        {
            throw new DNSSubmoduleException('We couldn\'t upload changes to your server check permissions for given account');
        }
        return $this->sftp;
    }

    /**
     * @param $recordTypeFilter
     * @return array
     * @throws DNSSubmoduleException
     */
    private function parseFileToStructure( $recordTypeFilter = false )
    {
        $this->updateZoneVariables();
        $lines = explode("\n", $this->zoneFile);
        $out   = [];

//            $this->validateFile($lines);
        $lines     = $this->setAndRemoveUsefullValues($lines);
        $this->ttl = $this->ttl ? : 14400;
        $soa       = $this->getSoaRecord($lines, $this->ttl);

        //If SOA in file we add it to out records and remove it so it makes it easier to parse file later (since soa can be multiline)
        if ( $soa )
        {
            $out[] = $soa;
            $lines = $this->removeSoaFromLine($lines, $soa->line);
        }

        //If multiple SOA records we throw exception
        if ( $this->getSoaRecord($lines) )
        {
            throw new DNSSubmoduleException('You can have only one SOA record in file');
        }

        $lines = $this->trimUnnecessaryBrackets($lines);

        $lines = $this->convertMultilineRecords($lines);

        //If filter records types is set we filter any other out
        if ( $recordTypeFilter )
        {
            $lines = $this->filterRecords($lines, $recordTypeFilter);
        }

        //Everything else in file should be record
        foreach( $lines as $nol => $line )
        {
            if( !trim($line) ) continue;
            $record = $this->buildRecord($line, $nol);

            if( $record )
            {
                $out[] = $record;
            }
        }

        return $out;
    }

    /**
     * Since Bin9 requires to update this value every time
     */
    private function incrementSOASerial()
    {
        foreach ( $this->records as &$structRecord )
        {
            if ( $structRecord->type === 'SOA' )
            {
                $structRecord->rdata->serial++;
            }
        }
        return $this;
    }

    /**
     * Runs named-checkzone on domain
     * @return string
     * @throws DNSSubmoduleException
     */
    private function checkZoneChanges()
    {
        $this->establishSSH2Connection();
        $pathToZone = $this->zoneFilePath;

        $result = $this->ssh2->exec("named-checkzone {$this->getDomainWithoutDot()} {$pathToZone}");
        if ( $this->ssh2->getExitStatus() )
        {
            return $result;
        }
        return true;
    }

    private function splitMultilineRecord( array $recordArray, string $recordRdata )
    {
        $out   = [];
        $out[] = implode(' ', $recordArray) . ' (';

        foreach( str_split($recordRdata, 200) as $splittedRdata )
        {
            $out[] = '"' . trim($splittedRdata, '"') . '"';
        }
        $out[count($out) - 1] .= ')';

        return $out;
    }


    private function trimUnnecessaryBrackets( array $lines )
    {
        foreach( $lines as &$line )
        {
            if( strpos($line, '(') !== false && strpos($line, ')') !== false )
            {
                $line = str_replace(['(', ')'], '', $line);
            }
        }

        return $lines;
    }

    private function buildRecord( $line, $nol )
    {
        //Replace @/blank/whitespace in first val for valid domain names
        $line   = $this->replaceName($line);
        $values = preg_split('/\s+/', $line);

        $recordType        = $this->getRecordType($values);
        $indexOfRecordType = $this->getIndexOfRecordType($values, $recordType);

        //If record type is not supported
        if( !$recordType )
        {
            throw new DNSSubmoduleException('We don\'t support this record type in line: ' . ($nol + 1));
        }

        $record       = new dns\record\Record();
        $record->name = $values[0];

        $record->line       = $nol;
        $record->type       = $values[$indexOfRecordType];
        $record->customData = $record->rdlength = '';

        $ttlandclass   = $this->getTTLAndClassFromValues($indexOfRecordType, $values, $this->ttl);
        $record->class = $ttlandclass['class'];
        $record->ttl   = $ttlandclass['ttl'];
        //We split string by whitespace but txt record type has spaces in one of the params so we have to merge them
        if( $recordType === 'TXT' )
        {
            preg_match('/".+"/', $line, $match);
            $values[$indexOfRecordType + 1] = str_replace("\t", ' ', $match[0]);
        }

        $className = 'MGModule\DNSManager2\mgLibs\custom\dns\record\type\\' . $recordType;

        $additionalVars = array_keys(get_class_vars($className));

        $recordTypeClass = new $className;

        foreach( $additionalVars as $index => $prop )
        {
            $recordTypeClass->$prop = $values[$indexOfRecordType + 1 + $index];
        }

        $record->rdata = $recordTypeClass;
        return $record;
    }

    private function convertMultilineRecords( array $lines )
    {
        $joinToLine = false;
        foreach( $lines as $nol => $line )
        {
            //If we are currently joining values and there is no closing bracket just join it
            if( $joinToLine !== false && strpos($line, ')') === false )
            {
                $lines[$joinToLine] .= trim($line);

                unset($lines[$nol]);

                continue;
            }

            //If there is closing bracket and we are joining values we add trimmed value without closing bracket and end joining
            if( $joinToLine !== false && strpos($line, ')') !== false )
            {
                $lines[$joinToLine] .= trim(str_replace(')', '', $line));
                $joinToLine         = false;

                unset($lines[$nol]);

                continue;
            }

            //If there is opening bracket in line and no closing brackets we start joining values
            if( strpos($line, '(') !== false && strpos($line, ')') === false )
            {
                $lines[$nol] = str_replace('(', '', $line);
                $joinToLine  = $nol;
            }
        }

        //Since we join values from multiple lines they are connected with double double quotes
        //We want to create single record so we have to remove this double quotes
        return array_map(static function( $line ) {
            return str_replace('""', '', $line);
        }, $lines);
    }
}
