<?php

/**
 * Admin notice helper class.
 *
 * Provides methods for displaying WordPress admin notices with proper escaping.
 *
 * @package Storeplugin\PDFInvoice
 * @since 2.0.0
 */
namespace Storeplugin\PDFInvoice\Framework\Utils;

defined('ABSPATH') || exit;

use RuntimeException;
use Storeplugin\PDFInvoice\Config;
use WP_Filesystem_Base;

final class File {
    /**
     * Maximum number of debug log entries stored in the database.
     */
    private const DEBUG_LOG_ENTRY_LIMIT = 100;

    /**
     * The WordPress filesystem instance.
     *
     * @var WP_Filesystem_Base|null
     */
    protected static $filesystem = null;

    /**
     * Whether to throw exceptions on failure.
     *
     * @var bool
     */
    public static $throw_exceptions = false;

    /**
     * Explicit log file path override.
     *
     * Set to a string to force file logging, `false` to disable logging,
     * or `null` to use the framework default storage.
     *
     * @var string|bool|null
     */
    public static $log_file = null;

    /**
     * Initialize the WP_Filesystem API if not already.
     *
     * @return bool True if initialized, false on failure.
     */
    protected static function initFilesystem() {
        if (self::$filesystem instanceof WP_Filesystem_Base) {
            return true;
        }

        if (! function_exists('WP_Filesystem')) {
            require_once ABSPATH . 'wp-admin/includes/file.php';
        }

        if (! WP_Filesystem()) {
            self::logError('WP_Filesystem initialization failed.');
            if (self::$throw_exceptions) {
                throw new RuntimeException('WP_Filesystem initialization failed.');
            }

            return false;
        }

        global $wp_filesystem;
        self::$filesystem = $wp_filesystem;

        return true;
    }

    /**
     * Safely log an error message.
     *
     * @param string $message
     */
    protected static function logError($message) {
        self::appendToDefaultLog($message);
    }

    /**
     * Append a line to the plugin-managed debug log.
     *
     * @param string $message
     * @return void
     */
    public static function appendToDefaultLog($message) {
        if (! self::isDebugLoggingEnabled() || self::$log_file === false) {
            return;
        }

        $timestamp = current_time('mysql', true);
        $line = "[$timestamp] $message\n";

        $file_path = is_string(self::$log_file) && self::$log_file !== ''
            ? self::$log_file
            : self::resolveConfiguredDebugLogFile();

        if (is_string($file_path) && $file_path !== '') {
            self::appendToFileLog($file_path, $line);
            return;
        }

        self::appendToStoredLog($timestamp, (string) $message);
    }

    /**
     * Check whether debug logging is enabled.
     *
     * @return bool
     */
    protected static function isDebugLoggingEnabled() {
        return defined('WP_DEBUG_LOG') && WP_DEBUG_LOG;
    }

    /**
     * Return the plugin storage slug used for uploads and temp directories.
     *
     * @return string|bool
     */
    public static function getPluginStorageSlug() {
        $plugin_slug = sanitize_key((string) Config::name());
        if ($plugin_slug === '') {
            $plugin_slug = sanitize_key((string) Config::prefix());
        }

        return $plugin_slug !== '' ? $plugin_slug : false;
    }

    /**
     * Get the plugin-specific uploads path.
     *
     * @param string $subdirectory Optional subdirectory under the plugin uploads root.
     * @return string|bool
     */
    public static function getPluginUploadsPath($subdirectory = '') {
        $upload_dir = wp_upload_dir();
        if (! empty($upload_dir['error']) || empty($upload_dir['basedir'])) {
            return false;
        }

        $plugin_slug = self::getPluginStorageSlug();
        if (! is_string($plugin_slug) || $plugin_slug === '') {
            return false;
        }

        $path = trailingslashit(wp_normalize_path($upload_dir['basedir'])) . $plugin_slug;
        $subdirectory = trim((string) $subdirectory, '/');

        if ($subdirectory !== '') {
            $path .= '/' . $subdirectory;
        }

        return $path;
    }

    /**
     * Resolve a custom WP_DEBUG_LOG file path when it points outside the site root.
     *
     * @return string|bool
     */
    protected static function resolveConfiguredDebugLogFile() {
        if (! defined('WP_DEBUG_LOG') || ! is_string(WP_DEBUG_LOG) || WP_DEBUG_LOG === '') {
            return false;
        }

        $file_path = wp_normalize_path(WP_DEBUG_LOG);
        if ($file_path === '') {
            return false;
        }

        if (defined('ABSPATH') && self::isPathWithinBase($file_path, wp_normalize_path(ABSPATH))) {
            return false;
        }

        return $file_path;
    }

    /**
     * Check whether a path is within a base directory.
     *
     * @param string $path
     * @param string $base_path
     * @return bool
     */
    protected static function isPathWithinBase($path, $base_path) {
        $path = wp_normalize_path($path);
        $base_path = trailingslashit(wp_normalize_path($base_path));

        return $path === rtrim($base_path, '/') || strpos($path, $base_path) === 0;
    }

    /**
     * Append a line to an explicit file log path.
     *
     * @param string $file_path
     * @param string $line
     * @return void
     */
    protected static function appendToFileLog($file_path, $line) {
        $dir = dirname(wp_normalize_path($file_path));
        if (! self::ensureDirectoryExists($dir)) {
            return;
        }

        if (self::$filesystem instanceof WP_Filesystem_Base) {
            $existing = self::$filesystem->exists($file_path)
                ? self::$filesystem->get_contents($file_path)
                : '';

            if ($existing === false) {
                $existing = '';
            }

            @self::$filesystem->put_contents($file_path, $existing . $line, FS_CHMOD_FILE);
        } else {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
            @file_put_contents($file_path, $line, FILE_APPEND);
        }
    }

    /**
     * Append a debug log entry to the database-backed log store.
     *
     * @param string $timestamp
     * @param string $message
     * @return void
     */
    protected static function appendToStoredLog($timestamp, $message) {
        $option_name = Config::option('debug_log');
        $entry = [
            'timestamp' => $timestamp,
            'message' => $message,
        ];

        $entries = get_option($option_name, null);
        if (! is_array($entries)) {
            update_option($option_name, [$entry], false);
            return;
        }

        $entries[] = $entry;
        $limit = self::getDebugLogEntryLimit();

        if (count($entries) > $limit) {
            $entries = array_slice($entries, -$limit);
        }

        update_option($option_name, $entries, false);
    }

    /**
     * Get the maximum number of stored debug log entries.
     *
     * @return int
     */
    protected static function getDebugLogEntryLimit() {
        $limit = (int) apply_filters('nxtpdf_debug_log_entry_limit', self::DEBUG_LOG_ENTRY_LIMIT);

        return $limit > 0 ? $limit : self::DEBUG_LOG_ENTRY_LIMIT;
    }

    /**
     * Ensure a directory exists.
     *
     * @param string $dir
     * @return bool
     */
    public static function ensureDirectoryExists($dir) {
        $dir = wp_normalize_path($dir);

        if (is_dir($dir)) {
            return true;
        }

        return wp_mkdir_p($dir);
    }

    /**
     * Harden a web-accessible directory with guard files.
     *
     * @param string $dir
     * @return bool
     */
    public static function hardenWebDirectory($dir) {
        $dir = wp_normalize_path($dir);

        if (! self::ensureDirectoryExists($dir)) {
            return false;
        }

        self::writeGuardFile($dir . '/index.php', "<?php\n// Silence is golden.\n");
        self::writeGuardFile(
            $dir . '/.htaccess',
            "<IfModule mod_authz_core.c>\n    Require all denied\n</IfModule>\n<IfModule !mod_authz_core.c>\n    Deny from all\n</IfModule>\n"
        );
        self::writeGuardFile(
            $dir . '/web.config',
            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<configuration>\n    <system.webServer>\n        <authorization>\n            <deny users=\"*\" />\n        </authorization>\n    </system.webServer>\n</configuration>\n"
        );

        return true;
    }

    /**
     * Create a guard file if it does not already exist.
     *
     * @param string $file_path
     * @param string $content
     * @return void
     */
    protected static function writeGuardFile($file_path, $content) {
        if (file_exists($file_path)) {
            return;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
        @file_put_contents($file_path, $content);
    }

    /**
     * Write contents to a file (like file_put_contents).
     *
     * @param string $file_path
     * @param string $content
     * @param int    $mode
     * @param bool   $append
     * @return int|false
     */
    public static function putContents($file_path, $content, $mode = FS_CHMOD_FILE, $append = false) {
        if (! self::initFilesystem()) {
            return false;
        }

        // Ensure directory exists
        $dir = dirname($file_path);
        if (! self::mkdir($dir, FS_CHMOD_DIR)) {
            return false;
        }

        if ($append && self::$filesystem->exists($file_path)) {
            $existing = self::$filesystem->get_contents($file_path);
            $content  = $existing . $content;
        }

        $result = self::$filesystem->put_contents($file_path, $content, $mode);
        if (! $result) {
            $msg = "Failed to write file: $file_path";
            self::logError($msg);
            if (self::$throw_exceptions) {
                throw new RuntimeException(esc_html($msg));
            }
            return false;
        }

        return strlen($content);
    }

    /**
     * Read a file or URL (like file_get_contents).
     *
     * @param string $file_or_url
     * @return string|false
     */
    public static function getContents($file_or_url) {
        // Remote URL case
        if (filter_var($file_or_url, FILTER_VALIDATE_URL)) {
            $safe_url = wp_http_validate_url($file_or_url);
            if (! $safe_url) {
                self::logError('Invalid URL provided: ' . $file_or_url);
                if (self::$throw_exceptions) {
                    throw new RuntimeException(sprintf('Invalid URL provided: %s', esc_html($file_or_url)));
                }
                return false;
            }

            $parsed_url = wp_parse_url($safe_url);
            $scheme = $parsed_url['scheme'] ?? '';
            if (! in_array($scheme, ['http', 'https'], true)) {
                self::logError('Unsupported URL scheme: ' . $safe_url);
                if (self::$throw_exceptions) {
                    throw new RuntimeException(sprintf('Unsupported URL scheme: %s', esc_html($safe_url)));
                }
                return false;
            }

            $response = wp_safe_remote_get($safe_url, [
                'timeout' => 10,
                'redirection' => 3,
                'limit_response_size' => 2 * MB_IN_BYTES,
            ]);

            if (is_wp_error($response)) {
                self::logError('Failed to fetch URL: ' . $safe_url . ' (' . $response->get_error_message() . ')');
                if (self::$throw_exceptions) {
                    throw new RuntimeException(sprintf('Failed to fetch URL: %s', esc_html($safe_url)));
                }
                return false;
            }
            return wp_remote_retrieve_body($response);
        }

        if (! self::initFilesystem()) {
            return false;
        }

        if (! self::$filesystem->exists($file_or_url)) {
            $msg = "File not found: $file_or_url";
            self::logError($msg);
            if (self::$throw_exceptions) {
                throw new RuntimeException(esc_html($msg));
            }
            return false;
        }

        $content = self::$filesystem->get_contents($file_or_url);
        if ($content === false) {
            $msg = "Failed to read file: $file_or_url";
            self::logError($msg);
            if (self::$throw_exceptions) {
                throw new RuntimeException(esc_html($msg));
            }
        }

        return $content;
    }

    /**
     * Send a file download response with headers and file contents.
     *
     * @param string $file_or_url
     * @param string $download_name
     * @param string $content_type
     * @return int|false Number of bytes sent, false on failure.
     */
    public static function sendDownload($file_or_url, $download_name = '', $content_type = 'application/octet-stream') {
        if (! self::prepareStreamOutput()) {
            return false;
        }

        $download_name = basename((string) $download_name);
        $download_name = str_replace(["\r", "\n", '"'], '', $download_name);
        if ($download_name === '') {
            $download_name = basename((string) $file_or_url);
        }

        nocache_headers();
        header('Content-Type: ' . $content_type);
        header('Content-Disposition: attachment; filename="' . $download_name . '"');

        if (is_string($file_or_url) && is_file($file_or_url)) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_filesize -- Content length is only added for directly readable local files.
            $fileSize = filesize($file_or_url);
            if ($fileSize !== false) {
                header('Content-Length: ' . $fileSize);
            }
        }

        return self::streamContents($file_or_url);
    }

    /**
     * Output a file or URL directly to the response (like readfile).
     *
     * @param string $file_or_url
     * @return int|false Number of bytes sent, false on failure.
     */
    public static function readFile($file_or_url) {
        if (! self::prepareStreamOutput()) {
            return false;
        }

        return self::streamContents($file_or_url);
    }

    /**
     * Prepare the response stream for raw file output.
     *
     * @return bool
     */
    protected static function prepareStreamOutput() {
        if (headers_sent($file, $line)) {
            $msg = sprintf('Cannot stream file because output has already started at %s:%d', $file ?: 'unknown', (int) $line);
            self::logError($msg);
            if (self::$throw_exceptions) {
                throw new RuntimeException(esc_html($msg));
            }
            return false;
        }

        while (ob_get_level() > 0) {
            if (! @ob_end_clean()) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Output buffers can fail to close if a handler is not removable; the method returns false in that case.
                $msg = 'Cannot stream file because an active output buffer could not be cleared.';
                self::logError($msg);
                if (self::$throw_exceptions) {
                    throw new RuntimeException(esc_html($msg));
                }
                return false;
            }
        }

        return true;
    }

    /**
     * Stream file contents after output is prepared.
     *
     * Streams directly readable local files in chunks. Other WP_Filesystem-backed
     * paths fall back to a buffered read because the WordPress filesystem API
     * does not expose a generic streaming read method.
     *
     * @param string $file_or_url
     * @return int|false Number of bytes sent, false on failure.
     */
    protected static function streamContents($file_or_url) {
        if (is_string($file_or_url) && is_file($file_or_url) && is_readable($file_or_url)) {
            return self::streamLocalFile($file_or_url);
        }

        $content = self::getContents($file_or_url);
        if ($content === false) {
            return false;
        }

        return self::streamStringContents($content);
    }

    /**
     * Stream a directly readable local file in chunks.
     *
     * @param string $file_path
     * @return int|false Number of bytes sent, false on failure.
     */
    protected static function streamLocalFile($file_path) {
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Native streaming avoids loading the whole local file into memory.
        $handle = @fopen($file_path, 'rb');
        if (! $handle) {
            $msg = "Failed to open file for reading: $file_path";
            self::logError($msg);
            if (self::$throw_exceptions) {
                throw new RuntimeException(esc_html($msg));
            }
            return false;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fpassthru -- Native passthrough streams the local file without loading it into memory.
        $bytesSent = fpassthru($handle);

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the native stream opened for local file streaming.
        fclose($handle);

        if ($bytesSent === false) {
            $msg = "Failed while streaming file: $file_path";
            self::logError($msg);
            if (self::$throw_exceptions) {
                throw new RuntimeException(esc_html($msg));
            }
            return false;
        }

        return $bytesSent;
    }

    /**
     * Stream an in-memory string via a temporary stream handle.
     *
     * @param string $content
     * @return int|false Number of bytes sent, false on failure.
     */
    protected static function streamStringContents($content) {
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Temporary in-memory stream is used only to forward response bytes safely.
        $handle = @fopen('php://temp', 'w+b');
        if (! $handle) {
            $msg = 'Failed to open a temporary stream for file output.';
            self::logError($msg);
            if (self::$throw_exceptions) {
                throw new RuntimeException(esc_html($msg));
            }
            return false;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Writing to the temporary stream before passthrough output.
        $bytesWritten = fwrite($handle, $content);
        if ($bytesWritten === false || $bytesWritten < strlen($content)) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the temporary stream after a failed write.
            fclose($handle);
            $msg = 'Failed while buffering file output.';
            self::logError($msg);
            if (self::$throw_exceptions) {
                throw new RuntimeException(esc_html($msg));
            }
            return false;
        }

        rewind($handle);

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fpassthru -- Streaming the buffered bytes to the response.
        $bytesSent = fpassthru($handle);

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the temporary stream after passthrough output.
        fclose($handle);

        if ($bytesSent === false) {
            $msg = 'Failed while streaming buffered file output.';
            self::logError($msg);
            if (self::$throw_exceptions) {
                throw new RuntimeException(esc_html($msg));
            }
            return false;
        }

        return $bytesSent;
    }

    /**
     * Delete a file (like unlink).
     *
     * @param string $file
     * @return bool
     */
    public static function unlink($file) {
        if (! file_exists($file)) {
            self::logError("File not found for deletion: $file");
            return false;
        }

        $result = wp_delete_file($file);
        if (! $result) {
            $msg = "Failed to delete file: $file";
            self::logError($msg);
            if (self::$throw_exceptions) {
                throw new RuntimeException(esc_html($msg));
            }
        }

        return (bool) $result;
    }

    /**
     * Change file permissions (like chmod).
     *
     * @param string $file
     * @param int    $mode
     * @return bool
     */
    public static function chmod($file, $mode) {
        if (! self::initFilesystem()) {
            return false;
        }

        if (! self::$filesystem->exists($file)) {
            self::logError("File does not exist for chmod: $file");
            return false;
        }

        $result = self::$filesystem->chmod($file, $mode);
        if (! $result) {
            $msg = "Failed to chmod file: $file";
            self::logError($msg);
            if (self::$throw_exceptions) {
                throw new RuntimeException(esc_html($msg));
            }
        }

        return $result;
    }

    /**
     * Recursively create directories (like mkdir with recursive = true).
     *
     * @param string $dir
     * @param int    $mode
     * @return bool
     */
    public static function mkdir($dir, $mode = FS_CHMOD_DIR) {
        if (! self::initFilesystem()) {
            return false;
        }

        $dir = rtrim(wp_normalize_path($dir), '/');

        if (self::$filesystem->is_dir($dir)) {
            return true;
        }

        $parts = explode('/', $dir);
        $path  = '';
        foreach ($parts as $part) {
            if (empty($part)) {
                continue;
            }
            $path .= '/' . $part;
            if (! self::$filesystem->is_dir($path)) {
                if (! self::$filesystem->mkdir($path, $mode)) {
                    $msg = "Failed to create directory: $path";
                    self::logError($msg);
                    if (self::$throw_exceptions) {
                        throw new RuntimeException(esc_html($msg));
                    }
                    return false;
                }
            }
        }

        return true;
    }

    /**
     * Remove a directory and its contents recursively.
     *
     * @param string $dir
     * @return bool
     */
    public static function rmdir($dir) {
        if (! self::initFilesystem()) {
            return false;
        }

        if (! self::$filesystem->is_dir($dir)) {
            self::logError("Directory does not exist for removal: $dir");
            return false;
        }

        $result = self::$filesystem->rmdir($dir, true);
        if (! $result) {
            $msg = "Failed to remove directory: $dir";
            self::logError($msg);
            if (self::$throw_exceptions) {
                throw new RuntimeException(esc_html($msg));
            }
        }

        return $result;
    }

    /**
     * Copy a file.
     *
     * @param string $source
     * @param string $destination
     * @param int    $mode
     * @return bool
     */
    public static function copy($source, $destination, $mode = FS_CHMOD_FILE) {
        if (! self::initFilesystem()) {
            return false;
        }

        if (! self::$filesystem->exists($source)) {
            $msg = "Source file does not exist: $source";
            self::logError($msg);
            if (self::$throw_exceptions) {
                throw new RuntimeException(esc_html($msg));
            }
            return false;
        }

        $dest_dir = dirname($destination);
        if (! self::mkdir($dest_dir, FS_CHMOD_DIR)) {
            return false;
        }

        $content = self::$filesystem->get_contents($source);
        if ($content === false) {
            $msg = "Failed to read source file for copy: $source";
            self::logError($msg);
            if (self::$throw_exceptions) {
                throw new RuntimeException(esc_html($msg));
            }
            return false;
        }

        $result = self::$filesystem->put_contents($destination, $content, $mode);
        if (! $result) {
            $msg = "Failed to copy file to: $destination";
            self::logError($msg);
            if (self::$throw_exceptions) {
                throw new RuntimeException(esc_html($msg));
            }
            return false;
        }

        return true;
    }

    /**
     * Rename or move a file.
     *
     * @param string $source
     * @param string $destination
     * @return bool
     */
    public static function rename($source, $destination) {
        if (! self::initFilesystem()) {
            return false;
        }

        if (! self::$filesystem->exists($source)) {
            $msg = "Source file does not exist for rename: $source";
            self::logError($msg);
            if (self::$throw_exceptions) {
                throw new RuntimeException(esc_html($msg));
            }
            return false;
        }

        $dest_dir = dirname($destination);
        if (! self::mkdir($dest_dir, FS_CHMOD_DIR)) {
            return false;
        }

        $result = self::$filesystem->move($source, $destination);
        if (! $result) {
            $msg = "Failed to rename file from $source to $destination";
            self::logError($msg);
            if (self::$throw_exceptions) {
                throw new RuntimeException(esc_html($msg));
            }
            return false;
        }

        return true;
    }

}
