<?php
/**
 * Created by PhpStorm.
 * User: turgutsaricam
 * Date: 12/12/2018
 * Time: 18:49
 *
 * @since 1.8.0
 */

namespace WPCCrawler\Objects\File;

use Exception;
use Illuminate\Filesystem\Filesystem;
use Symfony\Component\DomCrawler\Crawler;
use WPCCrawler\Factory;
use WPCCrawler\Objects\Crawling\Bot\PostBot;
use WPCCrawler\Objects\Enums\InformationMessage;
use WPCCrawler\Objects\Enums\InformationType;
use WPCCrawler\Objects\Informing\Information;
use WPCCrawler\Objects\Informing\Informer;
use WPCCrawler\Objects\OptionsBox\OptionsBoxService;
use WPCCrawler\Objects\Settings\Enums\SettingInnerKey;
use WPCCrawler\Objects\ShortCode\ShortCodeApplier;
use WPCCrawler\Objects\Traits\FindAndReplaceTrait;
use WPCCrawler\Utils;

class FileService {

    use FindAndReplaceTrait;

    /** @var int Maximum length of file name */
    const MAX_FILE_NAME_LENGTH = 240;

    /** @var string Opening brackets for the short codes in the name of a file. */
    const SC_OPENING_BRACKETS = 'sc123sc';

    /** @var string Closing brackets for the short codes in the name of a file. */
    const SC_CLOSING_BRACKETS = 'cs321cs';

    /*
     *
     */

    /** @var FileService */
    private static $instance = null;

    /** @var Filesystem */
    private $fs = null;

    /** @var string */
    private $tempDir = null;

    /** @var string Temporary file storage directory path relative to WP's uploads directory */
    protected $relativeTempDirPath = '/wpcc-temp';

    /**
     * Get the instance
     *
     * @return FileService
     * @since 1.8.0
     */
    public static function getInstance(): FileService {
        if (static::$instance === null) {
            static::$instance = new FileService();
        }

        return static::$instance;
    }

    /** This is a singleton. */
    protected function __construct() {}

    /**
     * Get absolute path under WordPress' uploads directory for a relative path.
     *
     * @param string $relativePath A path relative to uploads directory of WordPress.
     * @return string|null If an error occurs, null. Otherwise, absolute path for the given relative path.
     * @since 1.8.0
     */
    public function getPathUnderUploadsDir($relativePath): ?string {
        $uploadsDir = $this->getUploadsDir();

        // Create the target directory path. The directory should be relative to the "uploads" directory.
        $relativePath = trim(str_replace('/', DIRECTORY_SEPARATOR, $relativePath), DIRECTORY_SEPARATOR);

        // The new directory path must start with the uploads directory path, since we want to restrict the move
        // operation. The user can move the file into another folder in uploads directory.
        try {
            $newDirectoryPath = $this->getAbsolutePath($relativePath, $uploadsDir);
        } catch (Exception $e) {
            Informer::addError(
                sprintf(
                    _wpcc('%1$s directory is not in %2$s directory. The file can only be moved to a directory that 
                    is under the uploads directory of WordPress.'),
                    $uploadsDir . DIRECTORY_SEPARATOR . $relativePath,
                    $uploadsDir
                )
            )->addAsLog();
            return null;
        }

        return $newDirectoryPath;
    }

    /**
     * Get absolute path of the file by restricting defining a path that is above the given root path.
     *
     * @param string      $filePath Relative file path
     * @param string|null $root     Root directory path
     * @return string Absolute path of the file
     * @throws Exception If the given file path tries to go above the root.
     * @since 1.8.0
     * @since 1.13.0 Make `$root` nullable, and make it `null` by default
     * @see   https://stackoverflow.com/a/39796579/2883487
     */
    public function getAbsolutePath(string $filePath, ?string $root = null): string {
        $filePath = str_replace('/', DIRECTORY_SEPARATOR, $filePath);

        $path = [];
        foreach (explode(DIRECTORY_SEPARATOR, $filePath) as $part) {
            // Ignore empty parts and current directory parts
            if (empty($part) || $part === '.') continue;

            if ($part !== '..') {
                // We found a new part. Add it to the parts.
                $path[] = $part;

            } else if (count($path) > 0) {
                // Going back up is only allowed if there is at least one path.
                array_pop($path);

            } else {
                // Going back up is not allowed if there is no path.
                throw new Exception('Climbing above the root is not permitted.');
            }
        }

        // Prepend the root directory to the found parts.
        array_unshift($path, $root ?? '');

        // Combine the parts with directory separator and return.
        return join(DIRECTORY_SEPARATOR, $path);
    }

    /**
     * Get a valid file name. This makes sure there is no directory separators in the file name.
     *
     * @param string|null $fileName File name to be validated.
     * @return false|string If the name cannot be made valid, false. Otherwise, a valid name.
     * @since 1.8.0
     */
    public function validateFileName(?string $fileName) {
        // Make sure the new name does not contain any directory separators
        $fileName = $this->forceDirectorySeparator($fileName);
        if (!$fileName) return false;

        // Get the parts
        $parts = explode(DIRECTORY_SEPARATOR, $fileName);

        // Get the last part.
        $fileName = array_pop($parts);

        // If the new name does not exist, return false.
        if (!$fileName) return false;

        // Replace the generative short codes with internal short codes. The internal short codes are simple, meaning
        // that they do not contain additional attributes. On the other hand, the generative short codes can contain
        // additional attributes. If we keep them as-is, the spaces and quotes in the short codes are replaced, making
        // the short code invalid. By replacing them with simple internal short codes, we avoid this problem. We will
        // later restore the original values from the internal short codes.
        $fileName = (new ShortCodeApplier([]))->replaceWithInternalShortCodes($fileName);

        // Replace remaining short codes' brackets with URL-safe brackets. This is done also because we do not want the
        // short codes in the name of the files get replaced by a short code replacement operation that might be done in
        // PostTemplatePreparer. Because, name of the files cannot be changed simply. They need to be moved to their
        // new location in the file system. Making this replacement will achieve this. Later on, we can replace the
        // short codes using the opening and closing brackets defined as constants here.
        $fileName = $this->findAndReplaceSingle(
            '/\[([^\]]+)\]/',
            static::SC_OPENING_BRACKETS . '$1' . static::SC_CLOSING_BRACKETS,
            $fileName,
            true
        );

        // Make the name suitable for a URL. This also limits the length to 200 chars.
        $fileName = sanitize_title($fileName);

        // Make sure the file name length is in the limits
        if (mb_strlen($fileName) > static::MAX_FILE_NAME_LENGTH) {
            // If not, trim it such that it is in the limits.
            $fileName = mb_substr($fileName, 0, static::MAX_FILE_NAME_LENGTH);
        }

        return $fileName;
    }

    /**
     * Changes forward and backward slashes with {@link DIRECTORY_SEPARATOR}
     *
     * @param string|null $path A path
     * @return string
     * @since 1.8.0
     */
    public function forceDirectorySeparator(?string $path): string {
        if (!$path) return '';

        return str_replace('\\', DIRECTORY_SEPARATOR, str_replace('/', DIRECTORY_SEPARATOR, $path));
    }

    /**
     * Get WordPress' uploads directory path.
     *
     * @return string Path for the uploads directory of WordPress. If the path could not be retrieved, returns null.
     * @since 1.8.0
     * @since 1.13.0 Returns `string`
     */
    public function getUploadsDir(): string {
        $dirArr = $this->getUploadDirArray();

        // The result contains 'error' when there is an error. If the path could not be retrieved, inform the user
        // about the error.
        if (isset($dirArr['error']) && $dirArr['error']) {
            Informer::addError(_wpcc('Upload directory could not be retrieved: ') . $dirArr['error'])->addAsLog();
            return DIRECTORY_SEPARATOR . 'tmp';
        }

        // Make sure the base directory path does not end with a directory separator
        return rtrim($dirArr['basedir'], DIRECTORY_SEPARATOR);
    }

    /**
     * Get URL of the file that is located in WordPress' uploads directory.
     *
     * @param string|null $path Path of the file that is under WP's uploads directory
     * @return null|string If the directory information cannot be retrieved from WordPress, null. Otherwise, URL for the
     *                     file.
     * @since 1.8.0
     */
    public function getUrlForPathUnderUploadsDir($path): ?string {
        if ($path === null) return null;

        // Get WordPress' upload directory path and URL
        $dirArr = $this->getUploadDirArray();
        if (isset($dirArr['error']) && $dirArr['error']) return null;

        // Make sure the base URL does not end with a forward slash
        $baseUploadsUrl = rtrim($dirArr['baseurl'], '/');
        $baseUploadsDir = $this->getUploadsDir();

        // Remove the base uploads directory path from the local file path
        $relativePath = trim(str_replace($baseUploadsDir, '', $path), DIRECTORY_SEPARATOR);

        // Replace directory separators in the relative file path with a forward slash, since URLs use forward slashes.
        $relativePath = str_replace(DIRECTORY_SEPARATOR, '/', $relativePath);

        // Create the local URL by appending relative URL to the base uploads URL
        return $baseUploadsUrl . '/' . $relativePath;
    }

    /**
     * Get unique file path under a directory.
     *
     * @param string      $baseName    File name with extension
     * @param string      $directory   Directory path. The file name will be unique to this directory.
     * @param null|string $currentPath Current path of the file, if exists.
     * @return string Absolute file path that is unique to the given directory
     * @since 1.8.0
     */
    public function getUniqueFilePath(string $baseName, string $directory, ?string $currentPath = null): string {
        $directory = rtrim($this->forceDirectorySeparator($directory), DIRECTORY_SEPARATOR);

        // If the new path is the same as the old one, do nothing and return the path.
        if ($currentPath === $directory . DIRECTORY_SEPARATOR . $baseName) return $currentPath;

        // Get required file information
        $ext        = $this->getFileSystem()->extension($baseName);
        $fileName   = $this->getFileSystem()->name($baseName);

        $count = 0;
        do {
            // Create the new path. If this is not the first try, then append a number to the file name.
            $newBaseName = $count > 0 ? "{$fileName}-{$count}.{$ext}" : "{$fileName}.{$ext}";
            $newPath = $directory . DIRECTORY_SEPARATOR . $newBaseName;

            $count++;
            // Check if the new path is a path to an existing file. If so, rename the file by appending a number to it.
        } while($this->getFileSystem()->exists($newPath));

        return $newPath;
    }

    /**
     * Saves the remote file of the media file to the local environment by using its source URL. After successful save,
     * sets the media file's local path and local URL.
     *
     * @param MediaFile $mediaFile
     * @param PostBot   $postBot
     * @return bool True on success. Otherwise, false.
     * @since 1.8.0
     */
    public function saveMediaFile(MediaFile $mediaFile, PostBot $postBot): bool {
        // Save the file
        $file = MediaService::getInstance()
            ->saveMedia($mediaFile->getSourceUrl(), MediaSavingOptions::fromSiteSettings($postBot->getSettingsImpl()));

        // If there is no file, continue with the next one.
        if (!$file) {
            // Inform the user.
            Informer::add(Information::fromInformationMessage(
                InformationMessage::FILE_COULD_NOT_BE_SAVED_ERROR,
                sprintf(_wpcc('Original URL: %1$s, Prepared URL: %2$s'), $mediaFile->getOriginalSourceUrl(), $mediaFile->getSourceUrl()),
                InformationType::INFO
            )->addAsLog());

            return false;
        }

        /**
         * Modify the saved media file. You can move the file, rename it, modify it by, say, adding a text
         * on it, etc.
         *
         * @param array   $file             An array containing data about the file, such as its path and URL.
         * @param string  $preparedMediaUrl The URL from which the file was retrieved
         * @param string  $postUrl          URL of the post page that stores the target media file
         * @param int     $siteId           Site ID
         * @param PostBot $postBot          PostBot itself
         *
         * @return array
         * @since 1.6.3
         */
        $file = apply_filters('wpcc/post/media/file', $file, $mediaFile->getSourceUrl(), $postBot->getPostUrl(), $postBot->getSiteId(), $postBot);

        // Set the local file path and URL of the media file
        $mediaFile
            ->setLocalPath($file['file'])
            ->setLocalUrl($file['url']);

        return true;
    }

    /**
     * Applies short codes to media file's name.
     *
     * @param MediaFile        $mediaFile
     * @param ShortCodeApplier $applierFile    Short code applier that is configured to work with the file-name-specific
     *                                         short code brackets
     * @param ShortCodeApplier $applierRegular Regular short code applier that works with the square brackets
     * @return array Find and replace configurations that can be used to replace old file URLs with the changed ones
     * @since 1.8.0
     */
    public function applyShortCodesToMediaFileName(MediaFile $mediaFile, ShortCodeApplier $applierFile,
                                                   ShortCodeApplier $applierRegular): array {
        $frForMedia = [];

        // Store the current local URL
        $prevLocalUrl = $mediaFile->getLocalUrl();

        // Replace short codes in the name of the file
        $newName = $applierFile->restoreOriginalTemplateFromInternal($mediaFile->getName() ?? '');
        $newName = $applierFile->apply($newName);
        $newName = $applierFile->clearPredefinedShortCodes($newName);
        // After the short codes having file-specific brackets are replaced, if they contain other short codes with
        // regular brackets (nested short codes), apply them as well.
        $newName = $applierRegular->apply($newName);
        $newName = $applierRegular->clearPredefinedShortCodes($newName);
        $mediaFile->rename($newName);

        // Add find-replace configs for the media's original URL and the previous local URL, that is the local URL
        // of the file before it has just been renamed. By this way, we will be able to change all possible URLs
        // with the right local URL.
        if ($prevLocalUrl !== $mediaFile->getLocalUrl()) {
            $frForMedia[] = $this->createFindReplaceConfigForUrl($prevLocalUrl, $mediaFile->getLocalUrl());
        }

        if ($mediaFile->getOriginalSourceUrl() !== $mediaFile->getLocalUrl()) {
            $frForMedia[] = $this->createFindReplaceConfigForUrl($mediaFile->getOriginalSourceUrl(), $mediaFile->getLocalUrl());
        }

        return $frForMedia;
    }

    /**
     * Prepares file data using file URL selectors
     *
     * @param PostBot $bot                  The bot that will be used to extract data
     * @param Crawler $crawler              The crawler from which the data will be extracted
     * @param array   $fileUrlSelectors     An array of selectors. Each selector is an array that should contain
     *                                      'selector', and 'attr' keys whose values are strings. 'selector' is a CSS
     *                                      selector, and 'attr' is the target attribute from which the content will
     *                                      be retrieved. Default 'attr' is 'src'.
     * @param bool    $singleResult         True if only one result is enough.
     * @return MediaFile[] Found data as a MediaFile array
     * @since 1.8.0
     */
    public function saveFilesWithSelectors(PostBot $bot, Crawler $crawler, $fileUrlSelectors, $singleResult = false): array {
        $mediaFiles = [];

        // Prepare the file data
        foreach($fileUrlSelectors as $selectorData) {
            $selector = Utils::array_get($selectorData, SettingInnerKey::SELECTOR);
            if (!$selector) continue;

            $attr = Utils::array_get($selectorData, SettingInnerKey::ATTRIBUTE);
            if (!$attr) $attr = 'src';

            // Get file data
            $fileData = $bot->extractData($crawler, $selector, [$attr, "alt", "title"], false, $singleResult, true);
            if (!$fileData) continue;

            // If the file data is not an array, make it an array.
            if (!is_array($fileData)) $fileData = [$fileData];

            // Try to get an options box applier for this selector data
            $applier = OptionsBoxService::getInstance()->createApplierFromSelectorData($selectorData);

            // Make replacements
            foreach ($fileData as $mFileData) {
                // Get the source URL
                // If the file data is an array
                if (is_array($mFileData)) {
                    // It must have an index of the given $attr.
                    if (!isset($mFileData[$attr])) {
                        // $attr index does not exist. Hence, we do not have a file URL. Continue with the next one.
                        continue;
                    }

                    $src = $mFileData[$attr];
                } else {
                    $src = $mFileData;
                }

                // If there is no URL, continue with the next one.
                if (!$src) continue;

                // Store the original source URL
                $original = $src;

                // Prepare the media URL
                try {
                    $src = $bot->resolveUrl($src);

                } catch (Exception $e) {
                    Informer::addError(_wpcc('URL could not be resolved.') . ' - ' . $src)->addAsLog();
                }

                // Create a media file for this file
                $mediaFile = (new MediaFile($src, null))
                    ->setOriginalSourceUrl($original);

                // Get "alt" and "title" values
                if (is_array($mFileData)) {
                    $mediaFile
                        ->setMediaAlt(Utils::array_get($mFileData, 'alt'))
                        ->setMediaTitle(Utils::array_get($mFileData, 'title'));
                }

                // Save the media file
                $success = $this->saveMediaFile($mediaFile, $bot);
                if (!$success) continue;

                // Apply file options box options
                if ($applier) $applier->apply($mediaFile);

                // Add it among others
                $mediaFiles[] = $mediaFile;

                // Stop if there should only be a single result.
                if ($singleResult) break;
            }

        }

        return $mediaFiles;
    }

    /**
     * @return string Absolute path of the plugin's temporary file storage directory
     * @since 1.8.0
     */
    public function getTempDirPath(): string {
        if ($this->tempDir === null) {
            // Get WordPress' upload directory path
            $baseUploadsDir = $this->getUploadsDir();

            $this->tempDir = $baseUploadsDir . DIRECTORY_SEPARATOR . ltrim($this->relativeTempDirPath, DIRECTORY_SEPARATOR);

            if (!$this->getFileSystem()->isDirectory($this->tempDir)) {
                $this->getFileSystem()->makeDirectory($this->tempDir, 0755, true);

                // If the directory still does not exist, notify the user.
                if (!$this->getFileSystem()->isDirectory($this->tempDir)) {
                    Informer::addError(sprintf(
                        _wpcc('Directory "%1$s" could not be created. Please make sure this directory exists and it is writable.'),
                        $this->tempDir
                    ))->addAsLog();
                }
            }

        }

        return $this->tempDir;
    }

    /**
     * @return Filesystem
     * @since 1.8.0
     */
    public function getFileSystem(): Filesystem {
        if ($this->fs === null) {
            $this->fs = Factory::fileSystem();
        }

        return $this->fs;
    }

    /**
     * Get uploads directory details of WordPress.
     *
     * @return array See {@link wp_upload_dir()}
     * @since 1.9.0
     * @uses wp_upload_dir()
     */
    private function getUploadDirArray() {
        // wp_get_upload_dir exists starting from 4.5. Hence, we use wp_upload_dir to support older versions of
        // WordPress. The function call below is the same call as wp_get_upload_dir does.
        return wp_upload_dir(null, false);
    }
}
