<?php

namespace WPSN\Modules\BulkTable\Admin;

use WPSN\Core\Container;
use WPSN\SEO\MetaKeys;

defined('ABSPATH') || exit;

final class BulkTableImporter
{
    /**
     * Internal marker used in ops/preview to represent an explicit "clear".
     *
     * We do not store the user-provided token in ops to keep apply step simple.
     */
    public const CLEAR_MARKER = '__WPSN_CLEAR__';

    private Container $container;

    public function __construct(Container $container)
    {
        $this->container = $container;
    }

    /**
     * Parse a CSV file and build a diff preview against current postmeta.
     *
     * Result structure stored in transient:
     * - total_rows
     * - total_changes
     * - errors[]
     * - changes[] (flat list for preview)
     * - ops[] (operations list to apply)
     */
    /**
     * @param array{apply_only_changed?:bool,empty_cells?:string,clear_token?:string,mapping?:string,rewrite_rules?:string} $options
     */
    public function parseAndDiff(string $filePath, array $options = []): array
    {
        $errors = [];

        $applyOnlyChanged = array_key_exists('apply_only_changed', $options) ? (bool) $options['apply_only_changed'] : true;
        $emptyCells = isset($options['empty_cells']) ? (string) $options['empty_cells'] : 'ignore';
        if (!in_array($emptyCells, ['ignore', 'clear'], true)) {
            $emptyCells = 'ignore';
        }
        $clearToken = isset($options['clear_token']) ? trim((string) $options['clear_token']) : '__CLEAR__';
        if ($clearToken === '') {
            $clearToken = '__CLEAR__';
        }

        $mapping = isset($options['mapping']) ? sanitize_key((string) $options['mapping']) : 'auto';
        if (!in_array($mapping, ['auto', 'id_only'], true)) {
            $mapping = 'auto';
        }

        $rewriteRulesRaw = isset($options['rewrite_rules']) ? (string) $options['rewrite_rules'] : '';
        $rewriteRules = PermalinkRewrite::parseRules($rewriteRulesRaw);

        [$header, $rows] = $this->readCsv($filePath, $errors);
        if (empty($header)) {
            return [
                'total_rows' => 0,
                'total_changes' => 0,
                'errors' => array_merge($errors, [__('CSV has no header row.', 'wp-seo-next')]),
                'changes' => [],
                'ops' => [],
            ];
        }

        $map = $this->mapHeader($header);
        $hasId = !empty($map['post_id']);
        $hasPermalink = !empty($map['permalink']);
        $hasSlug = !empty($map['post_slug']);

        if ($mapping === 'id_only' && !$hasId) {
            return [
                'total_rows' => 0,
                'total_changes' => 0,
                'errors' => array_merge($errors, [__('CSV must contain a "post_id" column.', 'wp-seo-next')]),
                'changes' => [],
                'ops' => [],
            ];
        }

        if ($mapping !== 'id_only' && !$hasId && !$hasPermalink && !$hasSlug) {
            return [
                'total_rows' => 0,
                'total_changes' => 0,
                'errors' => array_merge($errors, [__('CSV must contain "post_id" or "permalink" (or "post_slug") columns for mapping.', 'wp-seo-next')]),
                'changes' => [],
                'ops' => [],
            ];
        }

        $ops = [];
        $changes = [];
        $totalChanges = 0;

        foreach ($rows as $i => $row) {
            $rowNum = $i + 2; // header is row 1

            $resolved = $this->resolvePostFromRow($row, $map, $mapping, $rewriteRules);
            $postId = $resolved['post_id'];
            $mappedBy = $resolved['mapped_by'];
            $post = $resolved['post'];
            $origPath = isset($resolved['orig_path']) ? (string) $resolved['orig_path'] : '';
            $rewrittenPath = isset($resolved['rewritten_path']) ? (string) $resolved['rewritten_path'] : '';

            if ($postId <= 0 || !$post) {
                $ctx = '';
                if ($origPath !== '') {
                    if ($rewrittenPath !== '' && $rewrittenPath !== $origPath) {
                        $ctx = ' ' . sprintf(
                            /* translators: 1: original path, 2: rewritten path */
                            __('Permalink path: %1$s → %2$s.', 'wp-seo-next'),
                            $origPath,
                            $rewrittenPath
                        );
                    } else {
                        $ctx = ' ' . sprintf(
                            /* translators: %s: permalink path */
                            __('Permalink path: %s.', 'wp-seo-next'),
                            $origPath
                        );
                    }
                }

                $errors[] = sprintf(
                    /* translators: 1: row number, 2: mapping context */
                    __('Row %1$d: could not map row to a post (id/permalink/slug).%2$s', 'wp-seo-next'),
                    $rowNum,
                    $ctx
                );
                continue;
            }

            if (!$this->canEditPost($postId)) {
                $errors[] = sprintf(
                    /* translators: 1: row number, 2: post ID */
                    __('Row %1$d: no permission to edit post #%2$d.', 'wp-seo-next'),
                    $rowNum,
                    $postId
                );
                continue;
            }

            $fields = $this->extractFieldsFromRow($row, $map, $emptyCells, $clearToken);
            if (empty($fields)) {
                continue;
            }

            // Compute diff against current meta.
            $changedFields = [];
            foreach ($fields as $field => $newValue) {
                $old = $this->getCurrentValue($postId, $field);
                $compareNew = ($newValue === self::CLEAR_MARKER) ? '' : (string) $newValue;
                if ($applyOnlyChanged && trim((string) $old) === trim((string) $compareNew)) {
                    continue;
                }
                $changedFields[$field] = $newValue;

                $changes[] = [
                    'post_id' => $postId,
                    'post_title' => (string) get_the_title($postId),
                    'mapped_by' => $mappedBy,
                    'field' => $field,
                    'old' => (string) $old,
                    'new' => ($newValue === self::CLEAR_MARKER) ? (string) __('(clear)', 'wp-seo-next') : (string) $newValue,
                ];
                $totalChanges++;
            }

            if (!empty($changedFields)) {
                $ops[] = [
                    'post_id' => $postId,
                    'fields' => $changedFields,
                ];
            }
        }

        return [
            'total_rows' => count($rows),
            'total_changes' => $totalChanges,
            'errors' => $errors,
            'changes' => $changes,
            'ops' => $ops,
        ];
    }

    /**
     * Resolve a row to a post ID, supporting staging→production imports.
     *
     * Mapping logic:
     * - if mapping=id_only: require post_id
     * - if mapping=auto: try post_id, then permalink path, then slug (+ optional post_type)
     *
     * @param array<int,string> $row
     * @param array<string,int> $map
     * @param array<int,array{from:string,to:string,kind:string}> $rewriteRules
     * @return array{post_id:int,mapped_by:string,post:?\WP_Post,orig_path:string,rewritten_path:string}
     */
    private function resolvePostFromRow(array $row, array $map, string $mapping, array $rewriteRules = []): array
    {
        $postId = 0;
        $mappedBy = 'id';
        $post = null;

        $origPath = '';
        $rewrittenPath = '';

        // 1) Try post_id.
        if (isset($map['post_id'])) {
            $pidRaw = $row[$map['post_id']] ?? '';
            $pid = (int) trim((string) $pidRaw);
            if ($pid > 0) {
                $maybe = get_post($pid);
                if ($maybe instanceof \WP_Post) {
                    return [
                        'post_id' => $pid,
                        'mapped_by' => 'id',
                        'post' => $maybe,
                        'orig_path' => '',
                        'rewritten_path' => '',
                    ];
                }
                // keep pid for error context; allow fallback if mapping=auto.
                $postId = $pid;
            }
        }

        if ($mapping === 'id_only') {
            return [
                'post_id' => 0,
                'mapped_by' => 'id',
                'post' => null,
                'orig_path' => '',
                'rewritten_path' => '',
            ];
        }

        // 2) Try permalink path.
        $rawPermalink = '';
        if (isset($map['permalink'])) {
            $rawPermalink = trim((string) ($row[$map['permalink']] ?? ''));
        }

        if ($rawPermalink !== '') {
            $origPath = (string) (parse_url($rawPermalink, PHP_URL_PATH) ?? '');
            $origPath = '/' . ltrim($origPath, '/');

            $rewrittenPath = $origPath;
            if (!empty($rewriteRules)) {
                $rewrittenPath = PermalinkRewrite::rewritePathFromUrl($rawPermalink, $rewriteRules);
            }

            $path = $rewrittenPath;
            if ($path !== '/' && $path !== '') {
                $local = home_url($path);
                $pid = (int) url_to_postid($local);
                if ($pid > 0) {
                    $maybe = get_post($pid);
                    if ($maybe instanceof \WP_Post) {
                        return [
                            'post_id' => $pid,
                            'mapped_by' => (!empty($rewriteRules) && $path !== $origPath) ? 'permalink(rewrite)' : 'permalink',
                            'post' => $maybe,
                            'orig_path' => $origPath,
                            'rewritten_path' => $rewrittenPath,
                        ];
                    }
                }
            }
        }

        // 3) Try slug (+ optional post_type).
        $slug = '';
        if (isset($map['post_slug'])) {
            $slug = sanitize_title((string) ($row[$map['post_slug']] ?? ''));
        }
        if ($slug === '' && $rawPermalink !== '') {
            $pathForSlug = (string) (parse_url($rawPermalink, PHP_URL_PATH) ?? '');
            $pathForSlug = '/' . ltrim($pathForSlug, '/');
            if (!empty($rewriteRules)) {
                $pathForSlug = PermalinkRewrite::rewritePathFromUrl($rawPermalink, $rewriteRules);
            }
            $pathForSlug = trim($pathForSlug, '/');
            if ($pathForSlug !== '') {
                $parts = explode('/', $pathForSlug);
                $slug = sanitize_title((string) end($parts));
            }
        }

        $postType = '';
        if (isset($map['post_type'])) {
            $postType = sanitize_key((string) ($row[$map['post_type']] ?? ''));
        }

        if ($slug !== '') {
            $args = [
                'name' => $slug,
                'post_type' => $postType !== '' ? $postType : 'any',
                'post_status' => 'any',
                'posts_per_page' => 2,
                'fields' => 'ids',
                'no_found_rows' => true,
                'suppress_filters' => false,
            ];
            $q = new \WP_Query($args);
            $ids = is_array($q->posts) ? $q->posts : [];
            if (count($ids) === 1) {
                $pid = (int) $ids[0];
                if ($pid > 0) {
                    $maybe = get_post($pid);
                    if ($maybe instanceof \WP_Post) {
                        return [
                            'post_id' => $pid,
                            'mapped_by' => 'slug',
                            'post' => $maybe,
                            'orig_path' => $origPath,
                            'rewritten_path' => $rewrittenPath,
                        ];
                    }
                }
            }
        }

        // No match.
        return [
            'post_id' => 0,
            'mapped_by' => $mappedBy,
            'post' => $post,
            'orig_path' => $origPath,
            'rewritten_path' => $rewrittenPath,
        ];
    }

    /**
     * Apply previously parsed ops.
     *
     * @param array<int,array{post_id:int,fields:array<string,string>}> $ops
     */
    public function applyOps(array $ops): int
    {
        $applied = 0;

        foreach ($ops as $op) {
            $postId = (int) ($op['post_id'] ?? 0);
            if ($postId <= 0) {
                continue;
            }
            if (!$this->canEditPost($postId)) {
                continue;
            }

            $fields = isset($op['fields']) && is_array($op['fields']) ? $op['fields'] : [];
            if (empty($fields)) {
                continue;
            }

            $applied += $this->applyToPost($postId, $fields);

            // Internal event for caches/audit.
            $this->container->get('events')->dispatch('seo/meta/updated', [$postId, 'post']);
        }

        return $applied;
    }

    private function canEditPost(int $postId): bool
    {
        // WP-CLI is an admin context by nature; avoid blocking automation pipelines.
        if (defined('WP_CLI') && WP_CLI) {
            return true;
        }

        return current_user_can('edit_post', $postId);
    }

    /**
     * @param array<string,string> $fields
     */
    private function applyToPost(int $postId, array $fields): int
    {
        $count = 0;

        foreach ($fields as $field => $value) {
            $value = is_string($value) ? $value : '';

            // Internal marker used by preview/apply.
            if ($value === self::CLEAR_MARKER) {
                $value = '';
            }

            $value = trim($value);

            switch ($field) {
                case 'seo_title':
                    $count += $this->updateOrDelete($postId, MetaKeys::TITLE, sanitize_text_field($value));
                    break;
                case 'meta_description':
                    $count += $this->updateOrDelete($postId, MetaKeys::DESCRIPTION, sanitize_textarea_field($value));
                    break;
                case 'canonical':
                    $count += $this->updateOrDelete($postId, MetaKeys::CANONICAL, $this->sanitizeUrlAllowRelative($value));
                    break;
                case 'robots_index':
                    $v = $this->sanitizeRobotsIndex($value);
                    if ($v !== '' && $v !== 'default') {
                        update_post_meta($postId, MetaKeys::ROBOTS_INDEX, $v);
                        $count++;
                    } else {
                        if (delete_post_meta($postId, MetaKeys::ROBOTS_INDEX)) {
                            $count++;
                        }
                    }
                    break;
                case 'robots_follow':
                    $v = $this->sanitizeRobotsFollow($value);
                    if ($v !== '' && $v !== 'default') {
                        update_post_meta($postId, MetaKeys::ROBOTS_FOLLOW, $v);
                        $count++;
                    } else {
                        if (delete_post_meta($postId, MetaKeys::ROBOTS_FOLLOW)) {
                            $count++;
                        }
                    }
                    break;
                case 'og_title':
                    $count += $this->updateOrDelete($postId, MetaKeys::OG_TITLE, sanitize_text_field($value));
                    break;
                case 'og_description':
                    $count += $this->updateOrDelete($postId, MetaKeys::OG_DESCRIPTION, sanitize_textarea_field($value));
                    break;
                case 'og_image':
                    $count += $this->updateOrDelete($postId, MetaKeys::OG_IMAGE, $this->sanitizeUrlAllowRelative($value));
                    break;
                case 'twitter_title':
                    $count += $this->updateOrDelete($postId, MetaKeys::TWITTER_TITLE, sanitize_text_field($value));
                    break;
                case 'twitter_description':
                    $count += $this->updateOrDelete($postId, MetaKeys::TWITTER_DESCRIPTION, sanitize_textarea_field($value));
                    break;
                case 'twitter_image':
                    $count += $this->updateOrDelete($postId, MetaKeys::TWITTER_IMAGE, $this->sanitizeUrlAllowRelative($value));
                    break;
                default:
                    // ignore
                    break;
            }
        }

        return $count;
    }

    private function updateOrDelete(int $postId, string $metaKey, string $value): int
    {
        $value = trim($value);
        if ($value !== '') {
            update_post_meta($postId, $metaKey, $value);
            return 1;
        }

        return delete_post_meta($postId, $metaKey) ? 1 : 0;
    }

    /**
     * @return array{0:array<int,string>,1:array<int,array<int,string>>}
     */
    private function readCsv(string $filePath, array &$errors): array
    {
        $content = file_get_contents($filePath);
        if ($content === false) {
            $errors[] = __('Unable to read uploaded CSV file.', 'wp-seo-next');
            return [[], []];
        }

        // Detect delimiter using the first non-empty line.
        $delimiter = ',';
        $lines = preg_split('/\r\n|\r|\n/', $content);
        if (is_array($lines)) {
            foreach ($lines as $line) {
                $line = trim((string) $line);
                if ($line === '') {
                    continue;
                }
                $comma = substr_count($line, ',');
                $semi = substr_count($line, ';');
                if ($semi > $comma) {
                    $delimiter = ';';
                }
                break;
            }
        }

        $fh = fopen($filePath, 'r');
        if (!$fh) {
            $errors[] = __('Unable to open uploaded CSV file.', 'wp-seo-next');
            return [[], []];
        }

        $header = [];
        $rows = [];
        $rowIndex = 0;
        while (($data = fgetcsv($fh, 0, $delimiter)) !== false) {
            $rowIndex++;
            if (!is_array($data)) {
                continue;
            }
            if ($rowIndex === 1) {
                $header = array_map(static function ($v) {
                    $v = is_string($v) ? $v : '';
                    // Remove BOM
                    $v = preg_replace('/^\xEF\xBB\xBF/', '', $v) ?? $v;
                    return trim($v);
                }, $data);
                continue;
            }

            // Normalize row length.
            $row = [];
            foreach ($header as $idx => $name) {
                $row[$idx] = isset($data[$idx]) ? (string) $data[$idx] : '';
            }
            $rows[] = $row;
        }

        fclose($fh);

        return [$header, $rows];
    }

    /**
     * @param array<int,string> $header
     * @return array<string,int>
     */
    private function mapHeader(array $header): array
    {
        $aliases = [
            // required
            'post_id' => 'post_id',
            'id' => 'post_id',

            // mapping helpers (staging → production)
            'permalink' => 'permalink',
            'url' => 'permalink',
            'post_slug' => 'post_slug',
            'slug' => 'post_slug',
            'post_name' => 'post_slug',
            'post_type' => 'post_type',

            // core meta
            'seo_title' => 'seo_title',
            'title' => 'seo_title',
            MetaKeys::TITLE => 'seo_title',

            'meta_description' => 'meta_description',
            'description' => 'meta_description',
            MetaKeys::DESCRIPTION => 'meta_description',

            'canonical' => 'canonical',
            MetaKeys::CANONICAL => 'canonical',

            'robots_index' => 'robots_index',
            MetaKeys::ROBOTS_INDEX => 'robots_index',

            'robots_follow' => 'robots_follow',
            MetaKeys::ROBOTS_FOLLOW => 'robots_follow',

            // social
            'og_title' => 'og_title',
            MetaKeys::OG_TITLE => 'og_title',

            'og_description' => 'og_description',
            MetaKeys::OG_DESCRIPTION => 'og_description',

            'og_image' => 'og_image',
            MetaKeys::OG_IMAGE => 'og_image',

            'twitter_title' => 'twitter_title',
            MetaKeys::TWITTER_TITLE => 'twitter_title',

            'twitter_description' => 'twitter_description',
            MetaKeys::TWITTER_DESCRIPTION => 'twitter_description',

            'twitter_image' => 'twitter_image',
            MetaKeys::TWITTER_IMAGE => 'twitter_image',
        ];

        $map = [];
        foreach ($header as $idx => $name) {
            $key = strtolower(trim((string) $name));
            if ($key === '') {
                continue;
            }

            // If header is a meta key constant (starts with _wpsn_), keep original.
            $raw = trim((string) $name);
            if (isset($aliases[$raw])) {
                $map[$aliases[$raw]] = (int) $idx;
                continue;
            }

            if (isset($aliases[$key])) {
                $map[$aliases[$key]] = (int) $idx;
            }
        }

        return $map;
    }

    /**
     * @param array<int,string> $row
     * @param array<string,int> $map
     * @return array<string,string>
     */
    private function extractFieldsFromRow(array $row, array $map, string $emptyCells, string $clearToken): array
    {
        return $this->extractFieldsFromRowWithOptions($row, $map, $emptyCells, $clearToken);
    }

    /**
     * @param array<int,string> $row
     * @param array<string,int> $map
     * @return array<string,string>
     */
    private function extractFieldsFromRowWithOptions(array $row, array $map, string $emptyCells, string $clearToken): array
    {
        $fields = [];
        foreach ($map as $field => $idx) {
            if (in_array($field, ['post_id', 'permalink', 'post_slug', 'post_type', 'post_status', 'post_title'], true)) {
                continue;
            }

            if (!array_key_exists($idx, $row)) {
                continue;
            }

            $raw = (string) $row[$idx];
            $rawTrim = trim($raw);

            // Explicit clear token (e.g. __CLEAR__).
            if ($clearToken !== '' && $rawTrim !== '' && strcasecmp($rawTrim, $clearToken) === 0) {
                $fields[$field] = self::CLEAR_MARKER;
                continue;
            }

            // Empty cell behavior.
            if ($rawTrim === '' && $emptyCells === 'ignore') {
                continue;
            }

            $fields[$field] = $this->sanitizeIncomingValue($field, $rawTrim);
        }

        return $fields;
    }

    private function sanitizeIncomingValue(string $field, string $value): string
    {
        $value = trim($value);

        switch ($field) {
            case 'seo_title':
            case 'og_title':
            case 'twitter_title':
                return sanitize_text_field($value);

            case 'meta_description':
            case 'og_description':
            case 'twitter_description':
                return sanitize_textarea_field($value);

            case 'canonical':
            case 'og_image':
            case 'twitter_image':
                return $this->sanitizeUrlAllowRelative($value);

            case 'robots_index':
                return $this->sanitizeRobotsIndex($value);
            case 'robots_follow':
                return $this->sanitizeRobotsFollow($value);

            default:
                return sanitize_text_field($value);
        }
    }

    private function getCurrentValue(int $postId, string $field): string
    {
        switch ($field) {
            case 'seo_title':
                return (string) get_post_meta($postId, MetaKeys::TITLE, true);
            case 'meta_description':
                return (string) get_post_meta($postId, MetaKeys::DESCRIPTION, true);
            case 'canonical':
                return (string) get_post_meta($postId, MetaKeys::CANONICAL, true);
            case 'robots_index':
                return (string) get_post_meta($postId, MetaKeys::ROBOTS_INDEX, true);
            case 'robots_follow':
                return (string) get_post_meta($postId, MetaKeys::ROBOTS_FOLLOW, true);
            case 'og_title':
                return (string) get_post_meta($postId, MetaKeys::OG_TITLE, true);
            case 'og_description':
                return (string) get_post_meta($postId, MetaKeys::OG_DESCRIPTION, true);
            case 'og_image':
                return (string) get_post_meta($postId, MetaKeys::OG_IMAGE, true);
            case 'twitter_title':
                return (string) get_post_meta($postId, MetaKeys::TWITTER_TITLE, true);
            case 'twitter_description':
                return (string) get_post_meta($postId, MetaKeys::TWITTER_DESCRIPTION, true);
            case 'twitter_image':
                return (string) get_post_meta($postId, MetaKeys::TWITTER_IMAGE, true);
            default:
                return '';
        }
    }

    private function sanitizeRobotsIndex(string $value): string
    {
        $value = strtolower(trim($value));
        $allowed = ['default', 'index', 'noindex', ''];
        return in_array($value, $allowed, true) ? $value : '';
    }

    private function sanitizeRobotsFollow(string $value): string
    {
        $value = strtolower(trim($value));
        $allowed = ['default', 'follow', 'nofollow', ''];
        return in_array($value, $allowed, true) ? $value : '';
    }

    private function sanitizeUrlAllowRelative(string $url): string
    {
        $url = trim($url);
        if ($url === '') {
            return '';
        }

        if (strpos($url, '/') === 0 && strpos($url, '//') !== 0) {
            $url = home_url($url);
        }

        return esc_url_raw($url);
    }
}
