<?php

namespace App;

use AllowDynamicProperties;
use App\Exports\JsonMapExport;
use App\Models\ExportMap;
use App\Models\ExportMapTemplate;
use App\Traits\ExportMapCheckRules;
use Carbon\Carbon;
use Exception;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Maatwebsite\Excel\Facades\Excel;
use Mtc\ContentManager\Models\MediaUse;
use Mtc\MercuryDataModels\SalesChannelHistory;
use Mtc\MercuryDataModels\Vehicle;
use Mtc\MercuryDataModels\VehicleOffer;

#[AllowDynamicProperties] class ExportMapRepository
{
    use ExportMapCheckRules;

    private array $imageColumns = [
        'images',
        'images20',
    ];

    public function __construct(protected ExportMap $export_map)
    {
        //
    }

    public function getQuery()
    {
        return $this->buildQuery()->query;
    }

    /**
     * Apply query conditions (nested)
     *
     * @param Builder $query
     * @param $conditions
     * @return void
     */
    protected function applyQueryConditions(Builder $query, $conditions): void
    {
        $conditions = collect($conditions)
            ->map(fn ($condition) => $condition['value']);

        foreach ($conditions as $condition) {
            if ($condition['column'] === 'image_count') {
                $query->has('mediaUses', $condition['operator'], (int) $condition['value']);
                continue;
            }
            if ($condition['column'] === 'finance_examples_count') {
                $query->has('financeExamples', $condition['operator'], (int) $condition['value']);
                continue;
            }
            if ($this->isRelationshipCondition($condition)) {
                $this->handleRelationshipCondition($query, $condition);
                continue;
            }
            if (Str::endsWith($condition['column'], '_since')) {
                $condition['column'] = str_replace('_since', '', $condition['column']);
                $condition['value'] = Carbon::now()->subDays($condition['value']);
                $condition['operator'] = $this->invertComparisonOperator($condition['operator']);
            }

            match ($condition['type']) {
                'and' => $query->where(function ($subQuery) use ($condition) {
                    $operator = $condition['operator'];
                    $column = $condition['column'];
                    $value = $condition['value'];

                    $subQuery
                        ->when($operator === 'in', function ($q) use ($column, $value) {
                            $q->whereIn($column, array_map(trim(...), explode(',', $value ?? '')));
                        })
                        ->when($operator === 'not_in', function ($q) use ($column, $value) {
                            $q->whereNotIn($column, array_map(trim(...), explode(',', $value ?? '')));
                        })
                        ->when(!in_array($operator, ['in', 'not_in']), function ($q) use ($column, $operator, $value) {
                            $q->where($column, $operator, $value)
                                ->when(empty($value), fn ($innerQuery) => $innerQuery->orWhereNull($column));
                        });
                }),
                'or' => $query->orWhere($condition['column'], $condition['operator'], $condition['value']),
                'has' => $query->whereHas($condition['column']),
                'doesnt-have' => $query->whereDoesntHave($condition['column']),
                'null' => $query->whereNull($condition['column']),
                'or-null' => $query->orWhereNull($condition['column']),
                'not-null' => $query->whereNotNull($condition['column']),
                'or-not-null' => $query->orWhereNotNull($condition['column']),
                'where-sub' => $query->where(
                    fn ($query) => $this->applyQueryConditions($query, $conditions['conditions'])
                ),
                'or-where-sub' => $query->orWhere(
                    fn ($query) => $this->applyQueryConditions($query, $conditions['conditions'])
                ),
            };
        }
    }

    /**
     * Check if the condition is for relationship object
     * Related objects are joined via . (dot)
     *
     * @param $condition
     * @return bool
     */
    protected function isRelationshipCondition(array $condition): bool
    {
        return strpos((string) $condition['column'], '.') > 0;
    }

    protected function handleRelationshipCondition(Builder $query, array $condition)
    {
        $split = explode('.', (string) $condition['column']);

        $last = array_pop($split);
        $relationship = implode('.', $split);
        $arrayValue = array_map(trim(...), explode(',', (string) $condition['value']));

        if ($last === 'count') {
            $relationshipName = strtolower((string) preg_replace('/(?<!^)[A-Z]/', '_$0', $relationship)) . '_count';
            $query->withCount($relationship)
                ->having($relationshipName, $condition['operator'], $condition['value']);
        } elseif ($condition['operator'] === 'in') {
            $query->whereHas($relationship, fn ($related) => $related->whereIn($last, $arrayValue));
        } elseif ($condition['operator'] === 'not_in') {
            $query->whereHas($relationship, fn ($related) => $related->whereNotIn($last, $arrayValue));
        } else {
            $query->whereHas($relationship, fn ($related) => $related->where(
                $last,
                $condition['operator'],
                $condition['value']
            ));
        }
    }

    /**
     * Start eloquent query
     *
     * @return $this
     */
    protected function buildQuery()
    {
        $this->query = App::make(Config::get('export_maps.types.' . $this->export_map->type))->query()
            ->latest()
            ->limit(5000);
        $this->query->with($this->relationshipsInColumns());
        $this->applyQueryConditions($this->query, $this->export_map->conditions);

        return $this;
    }

    /**
     * Check which relationships should be eager loaded
     *
     * @return array
     */
    protected function relationshipsInColumns(): array
    {
        $model = App::make(Config::get('export_maps.types.' . $this->export_map->type))
            ->query()
            ->getModel();

        $fromColumns = collect($this->export_map->columns)
            ->filter(fn ($column) => (str_contains((string) $column, '.') && !Str::startsWith(
                $column,
                'value'
            )) || $this->isImageColumn($column) || $column === 'image_count' || $column === 'finance_examples_count')
            ->map(function ($column) use ($model) {
                if ($this->isImageColumn($column) || $column === 'image_count') {
                    return 'mediaUses.media';
                }

                if ($column === 'finance_examples_count') {
                    return 'financeExamples';
                }

                if (Str::startsWith($column, 'attribute.')) {
                    return 'attributeValues';
                }

                return $this->resolveRelationshipPath($column, $model);
            });

        // Relationships referenced by mapping-rule field paths (when.field, then.target, then.source).
        $fromRules = collect($this->export_map->mapping_rules ?? [])
            ->flatMap(function ($rule) {
                $paths = [(string) ($rule['when']['field'] ?? '')];
                foreach (($rule['then'] ?? []) as $action) {
                    $paths[] = (string) ($action['target'] ?? '');
                    $paths[] = (string) ($action['source'] ?? '');
                }

                return $paths;
            })
            ->filter(fn ($path) => str_contains($path, '.'))
            ->map(fn ($path) => $this->resolveRelationshipPath($path, $model));

        return $fromColumns
            ->merge($fromRules)
            ->filter()
            ->unique()
            ->values()
            ->toArray();
    }

    /**
     * Resolve the eager-loadable relationship path for a dotted column/field path.
     *
     * Walks each segment and keeps only genuine Eloquent relationships, stopping at
     * the first non-relationship segment (so scalars and virtual targets yield null).
     */
    private function resolveRelationshipPath(string $column, Model $model): ?string
    {
        $split = explode('.', $column);
        array_pop($split);

        $relationshipPath = [];
        $currentModel = $model;

        foreach ($split as $segment) {
            if (
                $currentModel && method_exists($currentModel, $segment)
                && $currentModel->$segment() instanceof \Illuminate\Database\Eloquent\Relations\Relation
            ) {
                $relationshipPath[] = $segment;
                $currentModel = $currentModel->$segment()->getRelated();
            } else {
                break;
            }
        }

        return !empty($relationshipPath) ? implode('.', $relationshipPath) : null;
    }

    /**
     * Get report data
     *
     * @throws FileNotFoundException
     */
    protected function getData()
    {
        $this->data = $this->mapEntries($this->query->get());

        return $this;
    }

    /**
     * Send export file
     *
     */
    protected function sendViaFpt($export)
    {
        // Outside production the map's real FTP/SFTP destination is replaced with a local disk so
        // staging/local instances can never push client data to a live (or another environment's)
        // location. Mirrors the sales-channel safeguard in config/filesystems.php. See AUTO-1699.
        $diverted = ! App::environment('production');

        try {
            Config::set('filesystems.disks.export-map', $diverted
                ? [
                    'driver' => 'local',
                    'root' => storage_path('app/fake-exports'),
                ]
                : [
                    'driver' => $this->export_map->driver,
                    'host' => $this->export_map->host,
                    'root' => $this->export_map->root,
                    'port' => $this->export_map->port,
                    'username' => $this->export_map->username,
                    'password' => $this->export_map->password,
                    'ssl' => $this->export_map->ssl,
                    'filename' => $this->export_map->filename,
                ]);
            if ($export instanceof JsonMapExport) {
                Storage::disk('export-map')->put(
                    $this->export_map->filename,
                    json_encode($export->toArray(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
                );
            } else {
                Excel::store(
                    $export,
                    $this->export_map->filename,
                    'export-map',
                );
            }

            // Verify the file actually landed on the destination disk. With SFTP/FTP drivers a
            // put()/store() can return without error even when delivery failed, so an explicit
            // exists() check confirms the upload (it also throws on a remote connection failure).
            $this->recordDeliveryResult(
                Storage::disk('export-map')->exists($this->export_map->filename)
            );
        } catch (Exception $exception) {
            // FTPS over OpenSSL 3.x frequently raises "unexpected eof while reading" during the
            // TLS shutdown of the data channel *after* the file bytes have already been transferred,
            // so ftp_fput() reports failure for a delivery that actually landed. Re-verify against
            // the destination before treating this as a failure; only a file that genuinely is not
            // there (or an unreachable host) is recorded as an error.
            if ($this->deliveredFileExists()) {
                $this->recordDeliveryResult(true, 'delivered despite a transport error on connection shutdown');

                return;
            }

            SalesChannelHistory::store(
                $this->export_map->salesChannelKey(),
                false,
                $exception->getMessage(),
                null,
                [
                    'filename' => $this->export_map->filename,
                    'file_verified' => false,
                    'driver' => $diverted ? 'local' : $this->export_map->driver,
                    'diverted' => $diverted,
                ]
            );
        }
    }

    /**
     * Record the outcome of an export delivery to the sales-channel history.
     */
    private function recordDeliveryResult(bool $fileVerified, ?string $note = null): void
    {
        $diverted = ! App::environment('production');

        $details = $fileVerified
            ? $this->data->count() . ' records exported to ' . $this->export_map->filename
                . ($diverted ? ' (diverted to a local disk on a non-production environment)' : '')
            : 'Exported file "' . $this->export_map->filename
                . '" was not found on the destination disk after upload';

        if ($note !== null) {
            $details .= ' (' . $note . ')';
        }

        SalesChannelHistory::store(
            $this->export_map->salesChannelKey(),
            $fileVerified,
            $details,
            null,
            [
                'filename' => $this->export_map->filename,
                'records' => $this->data->count(),
                'file_verified' => $fileVerified,
                'driver' => $diverted ? 'local' : $this->export_map->driver,
                'diverted' => $diverted,
            ]
        );
    }

    /**
     * Confirm the exported file is present on the destination, swallowing any connection
     * error so a genuine delivery failure is reported with the original transport exception.
     */
    private function deliveredFileExists(): bool
    {
        try {
            return Storage::disk('export-map')->exists($this->export_map->filename);
        } catch (Exception $exception) {
            return false;
        }
    }

    /**
     * Map data entries to selected columns
     *
     * @param Collection $data
     * @return Collection
     */
    protected function mapEntries(Collection $data): Collection
    {
        $columns = $this->export_map->columns ?? [];
        $rules = collect($this->export_map->mapping_rules ?? [])
            ->reject(fn ($rule) => !($rule['when'] ?? null) || empty($rule['then'] ?? []));
        $isJson = $this->export_map->format === 'json';

        return $data->map(function ($entry) use ($columns, $rules, $isJson) {
            $rowByField = [];
            foreach ($columns as $field) {
                $raw = $this->getFieldValue($entry, $field);
                $rowByField[$field] = $isJson ? $raw : $this->castForExport($raw);
            }

            foreach ($rules as $rule) {
                if ($this->evaluateCondition($entry, $rowByField, $rule['when'])) {
                    foreach ($rule['then'] as $action) {
                        $target = $action['target'];
                        $source = $action['source'] ?? '';
                        $value = $this->resolveFieldPlaceholders($action['value'] ?? null, $rowByField);
                        $actionType = $action['action'] ?? 'set';

                        match ($actionType) {
                            'append' => $rowByField[$target] = ($rowByField[$target] ?? '') . ($value ?? ''),
                            'prepend' => $rowByField[$target] = ($value ?? '') . ($rowByField[$target] ?? ''),
                            'merge' => $rowByField[$target] = $this->mergeValue(
                                $rowByField[$target] ?? '',
                                $rowByField[$source] ?? $this->getFieldValue($entry, $source),
                                $action['separator'] ?? ', '
                            ),
                            default => $rowByField[$target] = $value === null
                                ? null
                                : ($isJson ? $value : $this->castForExport($value)),
                        };
                    }
                }
            }

            $out = [];
            foreach ($columns as $key => $field) {
                $out[$key] = $rowByField[$field] ?? null;
            }

            return $out;
        });
    }


    protected function resolveFieldPlaceholders(?string $value, array $rowByField): ?string
    {
        if ($value === null || !str_contains($value, '{{')) {
            return $value;
        }

        return preg_replace_callback('/\{\{(\w+)\}\}/', function ($matches) use ($rowByField) {
            return $rowByField[$matches[1]] ?? '';
        }, $value);
    }

    /**
     * Accumulate a value into a target string, skipping empties so there are no
     * leading, trailing or doubled separators (mirrors collect()->filter()->implode()).
     */
    protected function mergeValue($current, $incoming, string $separator): string
    {
        $incoming = $this->castForExport($incoming);
        $incoming = $incoming === null ? '' : trim((string) $incoming);
        $current = (string) ($current ?? '');

        if ($incoming === '') {
            return $current;
        }

        return $current === '' ? $incoming : $current . $separator . $incoming;
    }

    /**
     * Get custom attribute (relationship or count of relationship entries
     *
     * @param Model $entry
     * @param string $column
     * @return mixed|string
     */
    protected function getCustomAttribute(Model $entry, ?string $column = null)
    {
        if (empty($column) || $column === 'empty') {
            return '';
        }

        if ($column === 'site_url') {
            if ($entry instanceof VehicleOffer) {
                return \App\Facades\Site::offerUrl($entry);
            }
            if ($entry instanceof Vehicle) {
                return \App\Facades\Site::vehicleUrl($entry);
            }
            return '';
        }

        if ($column === 'image_count') {
            return $entry->mediaUses
                ->filter(fn ($mediaUse) => $mediaUse->media)
                ->count();
        }

        if ($column === 'finance_examples_count') {
            return $entry->financeExamples->count();
        }

        if ($this->isImageColumn($column)) {
            $sorted = $entry->mediaUses
                ->filter(fn ($mediaUse) => $mediaUse->media)
                ->sortBy(fn (MediaUse $mediaUse) => $mediaUse->order);

            if (preg_match('/^image_(\d+)$/', $column, $matches)) {
                $index = (int) $matches[1] - 1;
                $mediaUse = $sorted->values()->get($index);
                return $mediaUse ? $mediaUse->media->getOriginalUrlAttribute(true) : '';
            }

            return $sorted
                ->when($column === 'images20', fn ($collection) => $collection->take(20))
                ->map(fn (MediaUse $mediaUse) => $mediaUse->media->getOriginalUrlAttribute(true))
                ->implode(',');
        }

        $split = explode('.', $column);
        $field = array_pop($split);

        while (!empty($split) && !empty($entry)) {
            $relationship = array_shift($split);
            $entry = $entry[$relationship];
        }

        if (empty($entry)) {
            return '';
        }

        if ($field === 'count') {
            return $entry->count();
        }

        if ($entry instanceof Collection) {
            return $entry->pluck($field)->implode(',');
        }

        if ($entry instanceof Model) {
            return $entry->getAttribute($field);
        }

        return '';
    }

    /**
     * Set the schedule for next report
     *
     * @return void
     */
    protected function setSchedule()
    {
        if ($this->export_map->active !== true) {
            $this->export_map->next_due_at = null;
        }

        if ($this->export_map->isDirty('next_due_at')) {
            return;
        }

        $scheduleColumns = [
            'schedule',
            'export_time',
            'export_day_of_week',
            'export_day_of_month',
        ];
        if ($this->export_map->isDirty($scheduleColumns)) {
            $this->export_map->next_due_at = $this->export_map->getNextDueAt(Carbon::now());
        }
    }

    private function invertComparisonOperator(string $operator): string
    {
        return match ($operator) {
            '>=' => '<=',
            '>' => '<',
            '<=' => '>=',
            '<' => '>',
            default => $operator,
        };
    }

    /**
     * Get due maps
     *
     * @return Collection
     */
    public function getDueMaps(): Collection
    {
        return $this->export_map->newQuery()
            ->active(1)
            ->where('next_due_at', '<=', Carbon::now())
            ->get();
    }

    /**
     * Export maps that are tied to a sales channel via their template.
     * An export map's sales channel is derived from its template
     * (template_id -> ExportMapTemplate.sales_channel), since export_maps has
     * no sales_channel column of its own.
     *
     * @return Collection<int, array{id:int,name:string,active:bool,ftp_export_enabled:bool,sales_channel:string,last_sent_at:mixed}>
     */
    public function salesChannelMaps(): Collection
    {
        $maps = $this->export_map->newQuery()
            ->whereNotNull('template_id')
            ->get();

        if ($maps->isEmpty()) {
            return collect();
        }

        $templates = ExportMapTemplate::query()
            ->whereIn('id', $maps->pluck('template_id')->unique()->values())
            ->whereNotNull('sales_channel')
            ->where('sales_channel', '!=', '')
            ->get()
            ->keyBy('id');

        return $maps
            ->map(function (ExportMap $exportMap) use ($templates) {
                $salesChannel = $templates->get($exportMap->template_id)?->sales_channel;

                if (empty($salesChannel)) {
                    return null;
                }

                return [
                    'id' => $exportMap->id,
                    'name' => $exportMap->name,
                    'active' => (bool) $exportMap->active,
                    'ftp_export_enabled' => (bool) $exportMap->ftp_export_enabled,
                    'sales_channel' => $salesChannel,
                    'last_sent_at' => $exportMap->last_sent_at,
                ];
            })
            ->filter()
            ->values();
    }

    /**
     * Run a export map
     * Get data, send and update next shedule time
     *
     * @param ExportMap $exportMap
     * @return void
     */
    public function run(ExportMap $exportMap)
    {
        $this->export_map = $exportMap;

        $export = $this->buildQuery()
            ->getData()
            ->getExport();

        if ($exportMap->ftp_export_enabled) {
            $this->sendViaFpt($export);

            $this->export_map->update([
                'next_due_at' => $this->export_map->getNextDueAt(Carbon::now()),
                'last_sent_at' => Carbon::now(),
            ]);
        }
    }

    public function download($filename = '', bool $asAttachment = true)
    {
        $export = $this->buildQuery()
            ->getData()
            ->getExport();

        if ($export instanceof JsonMapExport) {
            $payload = $export->toArray();

            if (!$asAttachment) {
                return response()->json($payload);
            }

            $body = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
            return response($body, 200, [
                'Content-Type' => 'application/json',
                'Content-Disposition' => 'attachment; filename="' . $filename . '"',
            ]);
        }

        return Excel::download(
            $export,
            $filename,
        );
    }

    private function getExport()
    {
        $exportClass = $this->export_map->exportClass();
        return (new $exportClass($this->data, $this->export_map));
    }

    private function isImageColumn(string $column): bool
    {
        return in_array($column, $this->imageColumns, true)
            || preg_match('/^image_\d+$/', $column);
    }
}
